Build Boomerang for Gmail

KINDAback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

A send-later companion whose hard problems are that a scheduled message is a promise nobody is watching, that the provider's own authorisation expires in ways that look like nothing at all, and that a retry after an ambiguous failure must not deliver the message twice.

estimated effort weekendthe files for this build are in the project pack

RuntimeNode with TypeScript on a machine that stays awake, queue in SQLiteAuthorisationYour own OAuth client, the narrowest scope that does the jobSendingCompose the message yourself and call the send method at the target timeQueueClaim with a lease, and generate the message identifier before the first attemptAlarmsAuthorisation health probed on a timer and shown on the front page

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the scheduler, the tick and the status page.

    Get it nodejs.org or your version manager. open ↗

    Verify node --version prints v22 or higher

  2. free

    Why The grant's publishing status decides how long the refresh token lives, and using somebody else's client means you cannot see or change it.

    Get it Create a project, enable the mail API, configure the consent screen and create an OAuth client. open ↗

    Verify A consent flow completes and one message sends

  3. have readyfree if you have one, otherwise a few pounds a month

    Why There is no server-side scheduling here; if this process is asleep at the send time, the message is late or skipped.

    Get it A small always-on box, or a cheap virtual machine.

    Verify A message scheduled overnight sends on time with no catch-up note

  4. free

    Why Threading and the duplicate-after-timeout test are only meaningful against a real destination, and testing them against yourself hides threading faults.

    Get it Any second address on a different provider, which is better because it threads differently.

    Verify A scheduled reply threads correctly and a retried send leaves one message

  5. decidefree

    Why The choice determines whether this stays a personal tool or acquires a verification process, and deciding it while adding a feature means deciding it by accident.

    Get it Write down the scope, what it permits, and that reading bodies is out of scope for this build.

    Verify A test compares the code's scope list against the README

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- accounts(id, address, scopes_json, publishing_status, token_obtained_at, last_ok_at, state) · publishing status is stored because it changes how long the grant lives
- scheduled(id, account_id, state, send_at_utc, send_zone, catch_up_policy, created_at, claimed_at, lease_until, sent_at)
- drafts(scheduled_id, rfc822_path, message_id_header, thread_id, in_reply_to, references_header) · the identifier is generated before the first attempt and reused by every retry
- attempts(id, scheduled_id, at, outcome, http_status, error_text) · including the ambiguous ones, which are the interesting failures
- boomerangs(id, account_id, thread_id, return_at_utc, condition, satisfied_at, resurfaced_at) · condition is always, or only if nobody replied
- sync(account_id, last_history_id, last_full_sync_at, last_fallback_reason) · incremental sync with a recorded fallback, because the cursor does not live forever
- health(account_id, checked_at, outcome, detail) · every probe, so that 'it stopped working on Thursday' has an answer
- A scheduled message is a promise with a timestamp on it. Every table here exists so the promise is either kept or visibly broken, and never quietly missed.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/boomerang.dbThe queue, the drafts, the attempts and the health log.
GOOGLE_CLIENT_IDrequired1234-abcd.apps.googleusercontent.comYour own OAuth client, not a shared one.
GOOGLE_CLIENT_SECRETsecretrequiredGOCSPX-...redactedPaired with the client id. Never in the repository.
GOOGLE_SCOPESrequiredhttps://www.googleapis.com/auth/gmail.sendThe narrowest scope that sends. A test compares it against the README.
TOKEN_PATHsecretrequired./data/token.jsonThe refresh token. Its age is shown on the front page.
PROBE_INTERVAL_MINUTESrequired30How quickly a dead grant is noticed, which is the whole alarm.
DEFAULT_CATCH_UP_POLICYrequiredskip-and-notifyApplied when the sender does not choose. Late is not always better than never.
SENDER_ZONErequiredEurope/LondonThe zone scheduling times are entered in. Stored per message as well.

The build, in order

  1. Authorisation that tells you before it dies

    The front page knows the state of the grant and how old it is, without being asked.

    1. Reading mail bodies is a different classification and a different future.

      Files src/auth/scopes.tsREADME.md

    2. It is what determines how long the grant is going to live.

      Files src/auth/store.ts

    3. A health check nobody opens is a health check that does not exist.

      Files src/health.tssrc/views/home.tsx

    done when · tick each as it passes
    watch out
    • Google's documentation states that a project whose consent screen is configured for an external user type with a publishing status of 'Testing' is issued a refresh token expiring in seven days, unless the only scopes requested are a subset of name, email address and user profile. Checked on 15 September 2026. A send-later tool built against a testing client therefore works all week and stops on the eighth day, and the only symptom anybody sees is that Tuesday's message never arrived. Store the publishing status, probe on a timer, and put the clock where you will see it.
  2. A promise that is kept or broken loudly

    A message scheduled for a time the machine was asleep does what its stored policy says.

    1. Nine in the morning is a local fact and offsets move.

      Files src/schedule.tstests/dst.test.ts

    2. Nobody makes a good decision about it at eleven o'clock.

      Files src/schedule.tssrc/views/compose.tsx

    3. It is what makes a second tick harmless.

      Files src/queue.tstests/once.test.ts

    done when · tick each as it passes
    watch out
    • A laptop asleep at the send time wakes at eleven and a naive scheduler fires everything it missed at once, so a message written to land first thing arrives mid-morning with no acknowledgement that it is late, and three of them arrive together. Decide the catch-up policy when the message is scheduled rather than when it is missed, store it on the row, and make skip-and-tell-me a real option instead of assuming late beats never.
  3. A message that threads, and never arrives twice

    A scheduled reply lands in the right conversation and an ambiguous failure produces one message.

    1. It is the only handle you have after an ambiguous failure.

      Files src/compose.tstests/idempotent.test.ts

    2. Subject matching is not threading, whatever your own client shows.

      Files src/compose.ts

    3. Not at send time, which is when nobody is looking.

      Files src/validate.ts

    done when · tick each as it passes
    watch out
    • Replying by sending a fresh message with the same subject puts it in a different conversation for anybody whose client threads on identifiers rather than on subject lines, so the follow-up you carefully timed appears as a new email and the recipient answers the original instead. Generate the identifier yourself, set the in-reply-to and references headers from the thread, and reuse the same identifier on every retry so a timeout followed by a retry cannot deliver two copies.
  4. Bringing a thread back, only when it should come back

    A thread resurfaces on time, and not at all if somebody already replied.

    1. It is neither a crash nor an empty result.

      Files src/sync.tstests/sync-expiry.test.ts

    2. The tick will run twice eventually and the thread should not notice.

      Files src/boomerang.ts

    3. A reply on another thread is not a reply on this one.

      Files src/boomerang.tstests/condition.test.ts

    done when · tick each as it passes
    watch out
    • Incremental mail sync is keyed on a cursor the provider is entitled to stop honouring after a while, and a client that treats that refusal as a fatal error stops syncing on the first quiet fortnight, while one that treats it as 'nothing has changed' silently misses every reply since. Read the documented behaviour for an expired cursor, detect that specific failure, fall back to a bounded full listing, and record the fallback so a gap in the log has an explanation.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more email.