Build Boomerang for Gmail
KINDAback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- 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 - accountfree
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 - 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 - accountfree
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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/boomerang.db | The queue, the drafts, the attempts and the health log. |
GOOGLE_CLIENT_ID | required | 1234-abcd.apps.googleusercontent.com | Your own OAuth client, not a shared one. |
GOOGLE_CLIENT_SECRETsecret | required | GOCSPX-...redacted | Paired with the client id. Never in the repository. |
GOOGLE_SCOPES | required | https://www.googleapis.com/auth/gmail.send | The narrowest scope that sends. A test compares it against the README. |
TOKEN_PATHsecret | required | ./data/token.json | The refresh token. Its age is shown on the front page. |
PROBE_INTERVAL_MINUTES | required | 30 | How quickly a dead grant is noticed, which is the whole alarm. |
DEFAULT_CATCH_UP_POLICY | required | skip-and-notify | Applied when the sender does not choose. Late is not always better than never. |
SENDER_ZONE | required | Europe/London | The zone scheduling times are entered in. Stored per message as well. |
The build, in order
Authorisation that tells you before it dies
The front page knows the state of the grant and how old it is, without being asked.
Reading mail bodies is a different classification and a different future.
Files
src/auth/scopes.tsREADME.mdIt is what determines how long the grant is going to live.
Files
src/auth/store.tsA 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 passeswatch 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.
A promise that is kept or broken loudly
A message scheduled for a time the machine was asleep does what its stored policy says.
Nine in the morning is a local fact and offsets move.
Files
src/schedule.tstests/dst.test.tsNobody makes a good decision about it at eleven o'clock.
Files
src/schedule.tssrc/views/compose.tsxIt is what makes a second tick harmless.
Files
src/queue.tstests/once.test.ts
done when · tick each as it passeswatch 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.
A message that threads, and never arrives twice
A scheduled reply lands in the right conversation and an ambiguous failure produces one message.
It is the only handle you have after an ambiguous failure.
Files
src/compose.tstests/idempotent.test.tsSubject matching is not threading, whatever your own client shows.
Files
src/compose.tsNot at send time, which is when nobody is looking.
Files
src/validate.ts
done when · tick each as it passeswatch 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.
Bringing a thread back, only when it should come back
A thread resurfaces on time, and not at all if somebody already replied.
It is neither a crash nor an empty result.
Files
src/sync.tstests/sync-expiry.test.tsThe tick will run twice eventually and the thread should not notice.
Files
src/boomerang.tsA reply on another thread is not a reply on this one.
Files
src/boomerang.tstests/condition.test.ts
done when · tick each as it passeswatch 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.
The day it stops working, and the day you share itproduct builder
Nothing is trapped in the queue and a scope change is a decision rather than an import.
So nothing anyone wrote is trapped in your database.
Files
src/export.tstests/export.test.tsThe gap between the two is the whole health of the thing.
Files
src/views/status.tsxA scope change has consequences beyond the code.
Files
src/auth/scopes.tstests/scopes.test.ts
done when · tick each as it passeswatch out- Adding a feature that reads message bodies moves you from a scope the provider classes as sensitive to one it classes as restricted, and an app using restricted scopes for anyone other than yourself must pass an independent third-party security assessment, repeated at least every twelve months, before it can be published. Checked on 15 September 2026. For a tool you run for yourself this is an exemption rather than a bill; the moment you hand it to three colleagues it is neither, so treat a scope change as a release decision and not as a convenient import.
That is the whole plan for Boomerang for Gmail. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Shared inboxes and delegated sending.
- Open and click tracking.
- Publishing this for other people.
- Inbox pause.
- team routing and shared inbox rules
- abuse response and security operations
- calendar-provider edge cases and timezone correctness
- provider-grade deliverability
- push reliability across devices
- A send window rather than a send instant, so a message lands sometime between eight and nine
- A weekly summary of what was sent late and why, built from the attempt log
- Recurring scheduled messages, which are worth doing only once the catch-up policy is proven
Need the files? The project pack on the verdict page hands your agent the whole brief · more email.