Vibecode Boomerang for Gmail
track this build4 phases, 12 steps, beginner friendly0%The visible Gmail scheduling add-on loop is buildable, but a credible replacement needs more than the first screen. Boomerang for Gmail earns its keep through deliverability, security, integration, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Boomerang for Gmail. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Boomerang for Gmail · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with TypeScript on a machine that stays awake, queue in SQLite | a scheduler asleep at eight in the morning has not scheduled anything | | Authorisation | Your own OAuth client, the narrowest scope that does the job | the scope you pick decides whether a third-party security assessment is ever in your future | | Sending | Compose the message yourself and call the send method at the target time | the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do | | Queue | Claim with a lease, and generate the message identifier before the first attempt | a retry after an ambiguous failure must not put two copies in somebody's inbox | | Alarms | Authorisation health probed on a timer and shown on the front page | a silent authorisation failure turns a promise into a message that simply never arrives | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the scheduler, the tick and the status page. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Your own Google Cloud project with an OAuth client** · 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. - Verify: A consent flow completes and one message sends - [ ] **A machine that stays awake** · free 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 - [ ] **A second mailbox you control** · 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 - [ ] **The scope you will use, written down** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Boomerang for Gmail, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Boomerang for Gmail The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal Gmail companion that sends messages later, brings threads back, and shouts before its authorisation dies to replace Boomerang for Gmail. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript on a machine that stays awake, queue in SQLite · a scheduler asleep at eight in the morning has not scheduled anything - Authorisation: Your own OAuth client, the narrowest scope that does the job · the scope you pick decides whether a third-party security assessment is ever in your future - Sending: Compose the message yourself and call the send method at the target time · the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do - Queue: Claim with a lease, and generate the message identifier before the first attempt · a retry after an ambiguous failure must not put two copies in somebody's inbox - Alarms: Authorisation health probed on a timer and shown on the front page · a silent authorisation failure turns a promise into a message that simply never arrives ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authorisation that tells you before it dies Build: Create your own OAuth client and request the narrowest scope that can send. Store the client's publishing status and the time the refresh token was obtained. Probe the grant on a timer, record every probe, and put the state and the token's age on the front page and in a health endpoint. Trap: 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. Done when: The front page shows the publishing status, the age of the refresh token and the time of the last successful probe. A grant revoked at the provider is detected within one probe interval and every future scheduled message is marked at risk rather than left quietly pending, tested against a stub returning an authorisation failure. The scope list in the code, the consent screen and the README are identical, asserted by a test that reads all three. A failed probe is recorded with its detail, not just a boolean. Do not build yet: No queue, sending, threading or return-to-inbox yet. ### Phase 2 · A promise that is kept or broken loudly Build: Schedule in the sender's zone, storing both the instant and the zone. Ask for a catch-up policy when the message is scheduled: send late with a note, or skip and tell me. The tick claims due messages with a lease before doing anything, and records every attempt including the timeouts. Trap: 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. Done when: A message whose send time passed while the process was stopped follows its stored policy, tested by stopping the clock and restarting. A message scheduled across a daylight saving change sends at the intended local time, tested at both transitions. Two ticks running at once send a due message exactly once, tested. Every attempt including timeouts appears in the attempt table. Do not build yet: No composing, threading, return-to-inbox or status page yet. ### Phase 3 · A message that threads, and never arrives twice Build: Compose the message yourself with a message identifier you generate before the first attempt. For a reply, set the in-reply-to and references headers from the thread being answered. Send through the send method and store the returned identifiers. Refuse an attachment above a stated size at scheduling time, checking the provider's current published limit rather than guessing one. Trap: 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. Done when: A scheduled reply appears inside the original conversation in two different mail clients, checked by hand once and recorded. The generated message identifier is stored before the first attempt and reused on retry, asserted in the attempt log. An injected timeout followed by a retry leaves exactly one message in the destination mailbox, tested against a real second account. An oversized attachment is refused when the message is scheduled, with the limit named in the message. Do not build yet: No return-to-inbox, sync or status page yet. ### Phase 4 · Bringing a thread back, only when it should come back Build: Track threads with a return time and a condition. Detect replies with incremental sync from a stored cursor, handling an expired cursor explicitly with a bounded full listing and recording that it happened. Resurfacing applies a label or moves the thread, and is idempotent. Trap: 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. Done when: An expired sync cursor triggers a bounded full listing and writes a fallback reason, tested with a stub returning the documented failure. A thread whose condition is 'only if nobody replied' does not resurface after a reply arrives, tested end to end with a second account. Resurfacing the same thread twice produces one label and one notification, tested. The sync cursor survives a restart and no reply is processed twice. Do not build yet: No status page, export or scope-change process yet. ### Phase 5 · The day it stops working, and the day you share it Build: Build a status page showing grant health, queue depth, the oldest pending message and the last successful send. Export every pending message as a standalone mail file plus a CSV of its schedule, so the queue can be opened in any mail client. On shutdown, print what is pending and when it was due. Document the scope in one place that a test compares against the code. Trap: 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. Done when: Export writes each pending message as a file a mail client opens, with its scheduled time in the accompanying CSV, tested by opening one. The status page shows grant health, queue depth, oldest pending and last successful send, and a stopped tick is visible within one interval. Stopping the service prints the pending list with deadlines rather than exiting silently. A test fails if the scope list in the code differs from the one documented in the README. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, team routing and delegated sending, which need an authorisation model per person and an audit trail before they are safe. - Open and click tracking, which means embedding a beacon in other people's mail without telling them and is the part of this category worth leaving out. - Publishing this for other people, which turns the scope question into a verification process and an annual assessment rather than a preference. - Inbox pause, which works by moving mail around under the provider's own rules and fails in ways that lose messages rather than delaying them. ### README must contain - That a testing-mode external OAuth client is issued a refresh token expiring in seven days, quoted from the provider's documentation with the date checked, and that this is why the front page shows the token's age - That the mail API has no scheduled-send endpoint, so this process is the thing that has to be awake - That the message identifier is generated before the first attempt and reused, and why that prevents a duplicate after a timeout - That the scope chosen here can send but not read bodies, and what changing it would cost - How to export the pending queue as mail files, and what shutdown prints ===== AGENTS.md ===== # Agent instructions · Boomerang for Gmail indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with TypeScript on a machine that stays awake, queue in SQLite, Your own OAuth client, the narrowest scope that does the job, Compose the message yourself and call the send method at the target time, Claim with a lease, and generate the message identifier before the first attempt, Authorisation health probed on a timer and shown on the front page. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Boomerang for Gmail 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 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. ### Steps 1. Ask for the narrowest scope that can send Reading mail bodies is a different classification and a different future. Files: `src/auth/scopes.ts`, `README.md` 2. Store the publishing status beside the token It is what determines how long the grant is going to live. Files: `src/auth/store.ts` 3. Probe on a timer and surface it on the front page A health check nobody opens is a health check that does not exist. Files: `src/health.ts`, `src/views/home.tsx` ### Done when - [ ] The front page shows grant state and token age - [ ] A revoked grant marks future messages at risk within one interval - [ ] Scopes match across code, consent screen and README - [ ] Probe failures record their detail ### 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. ## Phase 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. ### Steps 1. Store the instant and the zone, not just the instant Nine in the morning is a local fact and offsets move. Files: `src/schedule.ts`, `tests/dst.test.ts` 2. Ask for the catch-up policy at scheduling time Nobody makes a good decision about it at eleven o'clock. Files: `src/schedule.ts`, `src/views/compose.tsx` 3. Claim with a lease before touching the network It is what makes a second tick harmless. Files: `src/queue.ts`, `tests/once.test.ts` ### Done when - [ ] A missed send follows its stored catch-up policy - [ ] A send across a transition lands at the right local time - [ ] Concurrent ticks send once - [ ] Timeouts are recorded as attempts ### 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. ## Phase 3 · A message that threads, and never arrives twice A scheduled reply lands in the right conversation and an ambiguous failure produces one message. ### Steps 1. Generate the message identifier before the first attempt It is the only handle you have after an ambiguous failure. Files: `src/compose.ts`, `tests/idempotent.test.ts` 2. Set in-reply-to and references from the thread Subject matching is not threading, whatever your own client shows. Files: `src/compose.ts` 3. Refuse oversized attachments at scheduling time Not at send time, which is when nobody is looking. Files: `src/validate.ts` ### Done when - [ ] A scheduled reply threads in two clients - [ ] The identifier is generated once and reused on retry - [ ] A timeout plus retry delivers one message - [ ] Oversized attachments are refused when scheduled ### 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. ## Phase 4 · Bringing a thread back, only when it should come back A thread resurfaces on time, and not at all if somebody already replied. ### Steps 1. Handle the expired cursor as its own case It is neither a crash nor an empty result. Files: `src/sync.ts`, `tests/sync-expiry.test.ts` 2. Make resurfacing idempotent The tick will run twice eventually and the thread should not notice. Files: `src/boomerang.ts` 3. Check the reply condition against the thread, not the inbox A reply on another thread is not a reply on this one. Files: `src/boomerang.ts`, `tests/condition.test.ts` ### Done when - [ ] An expired cursor causes a recorded full listing - [ ] A replied thread does not resurface - [ ] Resurfacing twice changes nothing the second time - [ ] The cursor survives a restart without reprocessing ### 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. ## Not in this build - Shared inboxes and delegated sending. - Open and click tracking. - Publishing this for other people. - Inbox pause. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The queue, the drafts, the attempts and the health log. DATABASE_PATH=./data/boomerang.db # Required. Your own OAuth client, not a shared one. GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. The narrowest scope that sends. A test compares it against the README. GOOGLE_SCOPES=https://www.googleapis.com/auth/gmail.send # Required · secret. The refresh token. Its age is shown on the front page. TOKEN_PATH=./data/token.json # Required. How quickly a dead grant is noticed, which is the whole alarm. PROBE_INTERVAL_MINUTES=30 # Required. Applied when the sender does not choose. Late is not always better than never. DEFAULT_CATCH_UP_POLICY=skip-and-notify # Required. The zone scheduling times are entered in. Stored per message as well. SENDER_ZONE=Europe/London
You are building a lean indie version of Boomerang for Gmail. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Boomerang for Gmail · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with TypeScript on a machine that stays awake, queue in SQLite | a scheduler asleep at eight in the morning has not scheduled anything | | Authorisation | Your own OAuth client, the narrowest scope that does the job | the scope you pick decides whether a third-party security assessment is ever in your future | | Sending | Compose the message yourself and call the send method at the target time | the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do | | Queue | Claim with a lease, and generate the message identifier before the first attempt | a retry after an ambiguous failure must not put two copies in somebody's inbox | | Alarms | Authorisation health probed on a timer and shown on the front page | a silent authorisation failure turns a promise into a message that simply never arrives | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the scheduler, the tick and the status page. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Your own Google Cloud project with an OAuth client** · 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. - Verify: A consent flow completes and one message sends - [ ] **A machine that stays awake** · free 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 - [ ] **A second mailbox you control** · 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 - [ ] **The scope you will use, written down** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Boomerang for Gmail, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Boomerang for Gmail The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal Gmail companion that sends messages later, brings threads back, and shouts before its authorisation dies to replace Boomerang for Gmail. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript on a machine that stays awake, queue in SQLite · a scheduler asleep at eight in the morning has not scheduled anything - Authorisation: Your own OAuth client, the narrowest scope that does the job · the scope you pick decides whether a third-party security assessment is ever in your future - Sending: Compose the message yourself and call the send method at the target time · the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do - Queue: Claim with a lease, and generate the message identifier before the first attempt · a retry after an ambiguous failure must not put two copies in somebody's inbox - Alarms: Authorisation health probed on a timer and shown on the front page · a silent authorisation failure turns a promise into a message that simply never arrives ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authorisation that tells you before it dies Build: Create your own OAuth client and request the narrowest scope that can send. Store the client's publishing status and the time the refresh token was obtained. Probe the grant on a timer, record every probe, and put the state and the token's age on the front page and in a health endpoint. Trap: 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. Done when: The front page shows the publishing status, the age of the refresh token and the time of the last successful probe. A grant revoked at the provider is detected within one probe interval and every future scheduled message is marked at risk rather than left quietly pending, tested against a stub returning an authorisation failure. The scope list in the code, the consent screen and the README are identical, asserted by a test that reads all three. A failed probe is recorded with its detail, not just a boolean. Do not build yet: No queue, sending, threading or return-to-inbox yet. ### Phase 2 · A promise that is kept or broken loudly Build: Schedule in the sender's zone, storing both the instant and the zone. Ask for a catch-up policy when the message is scheduled: send late with a note, or skip and tell me. The tick claims due messages with a lease before doing anything, and records every attempt including the timeouts. Trap: 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. Done when: A message whose send time passed while the process was stopped follows its stored policy, tested by stopping the clock and restarting. A message scheduled across a daylight saving change sends at the intended local time, tested at both transitions. Two ticks running at once send a due message exactly once, tested. Every attempt including timeouts appears in the attempt table. Do not build yet: No composing, threading, return-to-inbox or status page yet. ### Phase 3 · A message that threads, and never arrives twice Build: Compose the message yourself with a message identifier you generate before the first attempt. For a reply, set the in-reply-to and references headers from the thread being answered. Send through the send method and store the returned identifiers. Refuse an attachment above a stated size at scheduling time, checking the provider's current published limit rather than guessing one. Trap: 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. Done when: A scheduled reply appears inside the original conversation in two different mail clients, checked by hand once and recorded. The generated message identifier is stored before the first attempt and reused on retry, asserted in the attempt log. An injected timeout followed by a retry leaves exactly one message in the destination mailbox, tested against a real second account. An oversized attachment is refused when the message is scheduled, with the limit named in the message. Do not build yet: No return-to-inbox, sync or status page yet. ### Phase 4 · Bringing a thread back, only when it should come back Build: Track threads with a return time and a condition. Detect replies with incremental sync from a stored cursor, handling an expired cursor explicitly with a bounded full listing and recording that it happened. Resurfacing applies a label or moves the thread, and is idempotent. Trap: 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. Done when: An expired sync cursor triggers a bounded full listing and writes a fallback reason, tested with a stub returning the documented failure. A thread whose condition is 'only if nobody replied' does not resurface after a reply arrives, tested end to end with a second account. Resurfacing the same thread twice produces one label and one notification, tested. The sync cursor survives a restart and no reply is processed twice. Do not build yet: No status page, export or scope-change process yet. ### Phase 5 · The day it stops working, and the day you share it Build: Build a status page showing grant health, queue depth, the oldest pending message and the last successful send. Export every pending message as a standalone mail file plus a CSV of its schedule, so the queue can be opened in any mail client. On shutdown, print what is pending and when it was due. Document the scope in one place that a test compares against the code. Trap: 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. Done when: Export writes each pending message as a file a mail client opens, with its scheduled time in the accompanying CSV, tested by opening one. The status page shows grant health, queue depth, oldest pending and last successful send, and a stopped tick is visible within one interval. Stopping the service prints the pending list with deadlines rather than exiting silently. A test fails if the scope list in the code differs from the one documented in the README. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, team routing and delegated sending, which need an authorisation model per person and an audit trail before they are safe. - Open and click tracking, which means embedding a beacon in other people's mail without telling them and is the part of this category worth leaving out. - Publishing this for other people, which turns the scope question into a verification process and an annual assessment rather than a preference. - Inbox pause, which works by moving mail around under the provider's own rules and fails in ways that lose messages rather than delaying them. ### README must contain - That a testing-mode external OAuth client is issued a refresh token expiring in seven days, quoted from the provider's documentation with the date checked, and that this is why the front page shows the token's age - That the mail API has no scheduled-send endpoint, so this process is the thing that has to be awake - That the message identifier is generated before the first attempt and reused, and why that prevents a duplicate after a timeout - That the scope chosen here can send but not read bodies, and what changing it would cost - How to export the pending queue as mail files, and what shutdown prints ===== AGENTS.md ===== # Agent instructions · Boomerang for Gmail indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with TypeScript on a machine that stays awake, queue in SQLite, Your own OAuth client, the narrowest scope that does the job, Compose the message yourself and call the send method at the target time, Claim with a lease, and generate the message identifier before the first attempt, Authorisation health probed on a timer and shown on the front page. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Boomerang for Gmail 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 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. ### Steps 1. Ask for the narrowest scope that can send Reading mail bodies is a different classification and a different future. Files: `src/auth/scopes.ts`, `README.md` 2. Store the publishing status beside the token It is what determines how long the grant is going to live. Files: `src/auth/store.ts` 3. Probe on a timer and surface it on the front page A health check nobody opens is a health check that does not exist. Files: `src/health.ts`, `src/views/home.tsx` ### Done when - [ ] The front page shows grant state and token age - [ ] A revoked grant marks future messages at risk within one interval - [ ] Scopes match across code, consent screen and README - [ ] Probe failures record their detail ### 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. ## Phase 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. ### Steps 1. Store the instant and the zone, not just the instant Nine in the morning is a local fact and offsets move. Files: `src/schedule.ts`, `tests/dst.test.ts` 2. Ask for the catch-up policy at scheduling time Nobody makes a good decision about it at eleven o'clock. Files: `src/schedule.ts`, `src/views/compose.tsx` 3. Claim with a lease before touching the network It is what makes a second tick harmless. Files: `src/queue.ts`, `tests/once.test.ts` ### Done when - [ ] A missed send follows its stored catch-up policy - [ ] A send across a transition lands at the right local time - [ ] Concurrent ticks send once - [ ] Timeouts are recorded as attempts ### 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. ## Phase 3 · A message that threads, and never arrives twice A scheduled reply lands in the right conversation and an ambiguous failure produces one message. ### Steps 1. Generate the message identifier before the first attempt It is the only handle you have after an ambiguous failure. Files: `src/compose.ts`, `tests/idempotent.test.ts` 2. Set in-reply-to and references from the thread Subject matching is not threading, whatever your own client shows. Files: `src/compose.ts` 3. Refuse oversized attachments at scheduling time Not at send time, which is when nobody is looking. Files: `src/validate.ts` ### Done when - [ ] A scheduled reply threads in two clients - [ ] The identifier is generated once and reused on retry - [ ] A timeout plus retry delivers one message - [ ] Oversized attachments are refused when scheduled ### 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. ## Phase 4 · Bringing a thread back, only when it should come back A thread resurfaces on time, and not at all if somebody already replied. ### Steps 1. Handle the expired cursor as its own case It is neither a crash nor an empty result. Files: `src/sync.ts`, `tests/sync-expiry.test.ts` 2. Make resurfacing idempotent The tick will run twice eventually and the thread should not notice. Files: `src/boomerang.ts` 3. Check the reply condition against the thread, not the inbox A reply on another thread is not a reply on this one. Files: `src/boomerang.ts`, `tests/condition.test.ts` ### Done when - [ ] An expired cursor causes a recorded full listing - [ ] A replied thread does not resurface - [ ] Resurfacing twice changes nothing the second time - [ ] The cursor survives a restart without reprocessing ### 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. ## Not in this build - Shared inboxes and delegated sending. - Open and click tracking. - Publishing this for other people. - Inbox pause. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The queue, the drafts, the attempts and the health log. DATABASE_PATH=./data/boomerang.db # Required. Your own OAuth client, not a shared one. GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. The narrowest scope that sends. A test compares it against the README. GOOGLE_SCOPES=https://www.googleapis.com/auth/gmail.send # Required · secret. The refresh token. Its age is shown on the front page. TOKEN_PATH=./data/token.json # Required. How quickly a dead grant is noticed, which is the whole alarm. PROBE_INTERVAL_MINUTES=30 # Required. Applied when the sender does not choose. Late is not always better than never. DEFAULT_CATCH_UP_POLICY=skip-and-notify # Required. The zone scheduling times are entered in. Stored per message as well. SENDER_ZONE=Europe/London
You are building a production product version of Boomerang for Gmail. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Boomerang for Gmail · product brief ## Problem The visible Gmail scheduling add-on loop is buildable, but a credible replacement needs more than the first screen. Boomerang for Gmail earns its keep through deliverability, security, integration, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A send-later tool that never quietly misses a message: the grant's health is on the front page, a missed time follows a policy chosen in advance, a retry after a timeout delivers one copy, and the whole queue exports as files any mail client can open. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - IMAP or Gmail OAuth credentials where supported - secure local keychain access ## Explicit non-goals for v1 - 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 ## Success criteria - The front page is confirmed to show grant state and token age - A missed send time is confirmed to follow its stored catch-up policy - A timeout followed by a retry is confirmed to deliver one message - An expired sync cursor is confirmed to fall back and record the reason - The pending queue is confirmed to export as openable mail files ===== BRIEF.md ===== # Build brief · Boomerang for Gmail The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal Gmail companion that sends messages later, brings threads back, and shouts before its authorisation dies to replace Boomerang for Gmail. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript on a machine that stays awake, queue in SQLite · a scheduler asleep at eight in the morning has not scheduled anything - Authorisation: Your own OAuth client, the narrowest scope that does the job · the scope you pick decides whether a third-party security assessment is ever in your future - Sending: Compose the message yourself and call the send method at the target time · the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do - Queue: Claim with a lease, and generate the message identifier before the first attempt · a retry after an ambiguous failure must not put two copies in somebody's inbox - Alarms: Authorisation health probed on a timer and shown on the front page · a silent authorisation failure turns a promise into a message that simply never arrives ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authorisation that tells you before it dies Build: Create your own OAuth client and request the narrowest scope that can send. Store the client's publishing status and the time the refresh token was obtained. Probe the grant on a timer, record every probe, and put the state and the token's age on the front page and in a health endpoint. Trap: 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. Done when: The front page shows the publishing status, the age of the refresh token and the time of the last successful probe. A grant revoked at the provider is detected within one probe interval and every future scheduled message is marked at risk rather than left quietly pending, tested against a stub returning an authorisation failure. The scope list in the code, the consent screen and the README are identical, asserted by a test that reads all three. A failed probe is recorded with its detail, not just a boolean. Do not build yet: No queue, sending, threading or return-to-inbox yet. ### Phase 2 · A promise that is kept or broken loudly Build: Schedule in the sender's zone, storing both the instant and the zone. Ask for a catch-up policy when the message is scheduled: send late with a note, or skip and tell me. The tick claims due messages with a lease before doing anything, and records every attempt including the timeouts. Trap: 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. Done when: A message whose send time passed while the process was stopped follows its stored policy, tested by stopping the clock and restarting. A message scheduled across a daylight saving change sends at the intended local time, tested at both transitions. Two ticks running at once send a due message exactly once, tested. Every attempt including timeouts appears in the attempt table. Do not build yet: No composing, threading, return-to-inbox or status page yet. ### Phase 3 · A message that threads, and never arrives twice Build: Compose the message yourself with a message identifier you generate before the first attempt. For a reply, set the in-reply-to and references headers from the thread being answered. Send through the send method and store the returned identifiers. Refuse an attachment above a stated size at scheduling time, checking the provider's current published limit rather than guessing one. Trap: 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. Done when: A scheduled reply appears inside the original conversation in two different mail clients, checked by hand once and recorded. The generated message identifier is stored before the first attempt and reused on retry, asserted in the attempt log. An injected timeout followed by a retry leaves exactly one message in the destination mailbox, tested against a real second account. An oversized attachment is refused when the message is scheduled, with the limit named in the message. Do not build yet: No return-to-inbox, sync or status page yet. ### Phase 4 · Bringing a thread back, only when it should come back Build: Track threads with a return time and a condition. Detect replies with incremental sync from a stored cursor, handling an expired cursor explicitly with a bounded full listing and recording that it happened. Resurfacing applies a label or moves the thread, and is idempotent. Trap: 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. Done when: An expired sync cursor triggers a bounded full listing and writes a fallback reason, tested with a stub returning the documented failure. A thread whose condition is 'only if nobody replied' does not resurface after a reply arrives, tested end to end with a second account. Resurfacing the same thread twice produces one label and one notification, tested. The sync cursor survives a restart and no reply is processed twice. Do not build yet: No status page, export or scope-change process yet. ### Phase 5 · The day it stops working, and the day you share it Build: Build a status page showing grant health, queue depth, the oldest pending message and the last successful send. Export every pending message as a standalone mail file plus a CSV of its schedule, so the queue can be opened in any mail client. On shutdown, print what is pending and when it was due. Document the scope in one place that a test compares against the code. Trap: 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. Done when: Export writes each pending message as a file a mail client opens, with its scheduled time in the accompanying CSV, tested by opening one. The status page shows grant health, queue depth, oldest pending and last successful send, and a stopped tick is visible within one interval. Stopping the service prints the pending list with deadlines rather than exiting silently. A test fails if the scope list in the code differs from the one documented in the README. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, team routing and delegated sending, which need an authorisation model per person and an audit trail before they are safe. - Open and click tracking, which means embedding a beacon in other people's mail without telling them and is the part of this category worth leaving out. - Publishing this for other people, which turns the scope question into a verification process and an annual assessment rather than a preference. - Inbox pause, which works by moving mail around under the provider's own rules and fails in ways that lose messages rather than delaying them. ### README must contain - That a testing-mode external OAuth client is issued a refresh token expiring in seven days, quoted from the provider's documentation with the date checked, and that this is why the front page shows the token's age - That the mail API has no scheduled-send endpoint, so this process is the thing that has to be awake - That the message identifier is generated before the first attempt and reused, and why that prevents a duplicate after a timeout - That the scope chosen here can send but not read bodies, and what changing it would cost - How to export the pending queue as mail files, and what shutdown prints ===== ARCHITECTURE.md ===== # Architecture · Boomerang for Gmail ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with TypeScript on a machine that stays awake, queue in SQLite | a scheduler asleep at eight in the morning has not scheduled anything | | Authorisation | Your own OAuth client, the narrowest scope that does the job | the scope you pick decides whether a third-party security assessment is ever in your future | | Sending | Compose the message yourself and call the send method at the target time | the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do | | Queue | Claim with a lease, and generate the message identifier before the first attempt | a retry after an ambiguous failure must not put two copies in somebody's inbox | | Alarms | Authorisation health probed on a timer and shown on the front page | a silent authorisation failure turns a promise into a message that simply never arrives | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Authorisation | Scopes, token storage and probing. | Any provider, provided the grant's health is probed rather than assumed. | | Queue | Claims, leases, catch-up policy and attempts. | Any durable store with a conditional update. Not a timer over an in-memory list. | | Compose | Headers, identifiers and threading. | Any builder, provided the identifier is generated before the first attempt. | | Sync | The cursor, expiry handling and reply detection. | Any mechanism, provided an expired cursor is its own handled case. | | Status | Health, queue depth and export. | Any presentation, provided the oldest pending message is visible. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · The queue, the drafts, the attempts and the health log. - `GOOGLE_CLIENT_ID` · required · Your own OAuth client, not a shared one. - `GOOGLE_CLIENT_SECRET` · required, secret · Paired with the client id. Never in the repository. - `GOOGLE_SCOPES` · required · The narrowest scope that sends. A test compares it against the README. - `TOKEN_PATH` · required, secret · The refresh token. Its age is shown on the front page. - `PROBE_INTERVAL_MINUTES` · required · How quickly a dead grant is noticed, which is the whole alarm. - `DEFAULT_CATCH_UP_POLICY` · required · Applied when the sender does not choose. Late is not always better than never. - `SENDER_ZONE` · required · The zone scheduling times are entered in. Stored per message as well. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Boomerang for Gmail product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node with TypeScript on a machine that stays awake, queue in SQLite, Your own OAuth client, the narrowest scope that does the job, Compose the message yourself and call the send method at the target time, Claim with a lease, and generate the message identifier before the first attempt, Authorisation health probed on a timer and shown on the front page. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · Boomerang for Gmail Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Authorisation that tells you before it dies The front page knows the state of the grant and how old it is, without being asked. ### Steps 1. Ask for the narrowest scope that can send Reading mail bodies is a different classification and a different future. Files: `src/auth/scopes.ts`, `README.md` 2. Store the publishing status beside the token It is what determines how long the grant is going to live. Files: `src/auth/store.ts` 3. Probe on a timer and surface it on the front page A health check nobody opens is a health check that does not exist. Files: `src/health.ts`, `src/views/home.tsx` ### Done when - [ ] The front page shows grant state and token age - [ ] A revoked grant marks future messages at risk within one interval - [ ] Scopes match across code, consent screen and README - [ ] Probe failures record their detail ### 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. ## M2 · A promise that is kept or broken loudly A message scheduled for a time the machine was asleep does what its stored policy says. ### Steps 1. Store the instant and the zone, not just the instant Nine in the morning is a local fact and offsets move. Files: `src/schedule.ts`, `tests/dst.test.ts` 2. Ask for the catch-up policy at scheduling time Nobody makes a good decision about it at eleven o'clock. Files: `src/schedule.ts`, `src/views/compose.tsx` 3. Claim with a lease before touching the network It is what makes a second tick harmless. Files: `src/queue.ts`, `tests/once.test.ts` ### Done when - [ ] A missed send follows its stored catch-up policy - [ ] A send across a transition lands at the right local time - [ ] Concurrent ticks send once - [ ] Timeouts are recorded as attempts ### 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. ## M3 · A message that threads, and never arrives twice A scheduled reply lands in the right conversation and an ambiguous failure produces one message. ### Steps 1. Generate the message identifier before the first attempt It is the only handle you have after an ambiguous failure. Files: `src/compose.ts`, `tests/idempotent.test.ts` 2. Set in-reply-to and references from the thread Subject matching is not threading, whatever your own client shows. Files: `src/compose.ts` 3. Refuse oversized attachments at scheduling time Not at send time, which is when nobody is looking. Files: `src/validate.ts` ### Done when - [ ] A scheduled reply threads in two clients - [ ] The identifier is generated once and reused on retry - [ ] A timeout plus retry delivers one message - [ ] Oversized attachments are refused when scheduled ### 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. ## M4 · Bringing a thread back, only when it should come back A thread resurfaces on time, and not at all if somebody already replied. ### Steps 1. Handle the expired cursor as its own case It is neither a crash nor an empty result. Files: `src/sync.ts`, `tests/sync-expiry.test.ts` 2. Make resurfacing idempotent The tick will run twice eventually and the thread should not notice. Files: `src/boomerang.ts` 3. Check the reply condition against the thread, not the inbox A reply on another thread is not a reply on this one. Files: `src/boomerang.ts`, `tests/condition.test.ts` ### Done when - [ ] An expired cursor causes a recorded full listing - [ ] A replied thread does not resurface - [ ] Resurfacing twice changes nothing the second time - [ ] The cursor survives a restart without reprocessing ### 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. ## M5 · The day it stops working, and the day you share it (production only) Nothing is trapped in the queue and a scope change is a decision rather than an import. ### Steps 1. Export the queue as files a mail client can open So nothing anyone wrote is trapped in your database. Files: `src/export.ts`, `tests/export.test.ts` 2. Show oldest pending and last successful send together The gap between the two is the whole health of the thing. Files: `src/views/status.tsx` 3. Pin the scope list and test it against the README A scope change has consequences beyond the code. Files: `src/auth/scopes.ts`, `tests/scopes.test.ts` ### Done when - [ ] Pending messages export as openable mail files - [ ] The status page shows the oldest pending and last send - [ ] Shutdown prints pending messages and deadlines - [ ] A scope drift between code and README fails a test ### Watch 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. ===== OPERATIONS.md ===== # Operations · Boomerang for Gmail ## Backup The database, several times a day rather than nightly, because a day's loss here is a set of promises nobody knows were made. The draft files beside it; they are the messages themselves. ## Restore Restore, then check for rows stuck in the claimed state with a live lease before starting the tick, and check the health probe before letting anything send. A restore that resumes with a dead grant will mark the whole queue at risk, which is the correct outcome and should not be suppressed. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Grant state and token age, oldest pending message, time since the last successful send, sync fallback count and attempt outcomes. The oldest pending message is the single number that catches almost everything. ## Incident checklist The incident is a message that did not arrive or arrived twice. The attempt log with its generated identifier tells you which, the health log tells you whether the grant was alive at the time, and the sync fallback record explains any gap in reply detection. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The front page is confirmed to show grant state and token age - [ ] A missed send time is confirmed to follow its stored catch-up policy - [ ] A timeout followed by a retry is confirmed to deliver one message - [ ] An expired sync cursor is confirmed to fall back and record the reason - [ ] The pending queue is confirmed to export as openable mail files ## Launch constraint Do not market omitted Boomerang for Gmail capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The queue, the drafts, the attempts and the health log. DATABASE_PATH=./data/boomerang.db # Required. Your own OAuth client, not a shared one. GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. The narrowest scope that sends. A test compares it against the README. GOOGLE_SCOPES=https://www.googleapis.com/auth/gmail.send # Required · secret. The refresh token. Its age is shown on the front page. TOKEN_PATH=./data/token.json # Required. How quickly a dead grant is noticed, which is the whole alarm. PROBE_INTERVAL_MINUTES=30 # Required. Applied when the sender does not choose. Late is not always better than never. DEFAULT_CATCH_UP_POLICY=skip-and-notify # Required. The zone scheduling times are entered in. Stored per message as well. SENDER_ZONE=Europe/London
# Boomerang for Gmail · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with TypeScript on a machine that stays awake, queue in SQLite | a scheduler asleep at eight in the morning has not scheduled anything | | Authorisation | Your own OAuth client, the narrowest scope that does the job | the scope you pick decides whether a third-party security assessment is ever in your future | | Sending | Compose the message yourself and call the send method at the target time | the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do | | Queue | Claim with a lease, and generate the message identifier before the first attempt | a retry after an ambiguous failure must not put two copies in somebody's inbox | | Alarms | Authorisation health probed on a timer and shown on the front page | a silent authorisation failure turns a promise into a message that simply never arrives | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the scheduler, the tick and the status page. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Your own Google Cloud project with an OAuth client** · 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. - Verify: A consent flow completes and one message sends - [ ] **A machine that stays awake** · free 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 - [ ] **A second mailbox you control** · 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 - [ ] **The scope you will use, written down** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Boomerang for Gmail, and the README should say so rather than pretend.
# Build brief · Boomerang for Gmail The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal Gmail companion that sends messages later, brings threads back, and shouts before its authorisation dies to replace Boomerang for Gmail. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript on a machine that stays awake, queue in SQLite · a scheduler asleep at eight in the morning has not scheduled anything - Authorisation: Your own OAuth client, the narrowest scope that does the job · the scope you pick decides whether a third-party security assessment is ever in your future - Sending: Compose the message yourself and call the send method at the target time · the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do - Queue: Claim with a lease, and generate the message identifier before the first attempt · a retry after an ambiguous failure must not put two copies in somebody's inbox - Alarms: Authorisation health probed on a timer and shown on the front page · a silent authorisation failure turns a promise into a message that simply never arrives ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authorisation that tells you before it dies Build: Create your own OAuth client and request the narrowest scope that can send. Store the client's publishing status and the time the refresh token was obtained. Probe the grant on a timer, record every probe, and put the state and the token's age on the front page and in a health endpoint. Trap: 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. Done when: The front page shows the publishing status, the age of the refresh token and the time of the last successful probe. A grant revoked at the provider is detected within one probe interval and every future scheduled message is marked at risk rather than left quietly pending, tested against a stub returning an authorisation failure. The scope list in the code, the consent screen and the README are identical, asserted by a test that reads all three. A failed probe is recorded with its detail, not just a boolean. Do not build yet: No queue, sending, threading or return-to-inbox yet. ### Phase 2 · A promise that is kept or broken loudly Build: Schedule in the sender's zone, storing both the instant and the zone. Ask for a catch-up policy when the message is scheduled: send late with a note, or skip and tell me. The tick claims due messages with a lease before doing anything, and records every attempt including the timeouts. Trap: 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. Done when: A message whose send time passed while the process was stopped follows its stored policy, tested by stopping the clock and restarting. A message scheduled across a daylight saving change sends at the intended local time, tested at both transitions. Two ticks running at once send a due message exactly once, tested. Every attempt including timeouts appears in the attempt table. Do not build yet: No composing, threading, return-to-inbox or status page yet. ### Phase 3 · A message that threads, and never arrives twice Build: Compose the message yourself with a message identifier you generate before the first attempt. For a reply, set the in-reply-to and references headers from the thread being answered. Send through the send method and store the returned identifiers. Refuse an attachment above a stated size at scheduling time, checking the provider's current published limit rather than guessing one. Trap: 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. Done when: A scheduled reply appears inside the original conversation in two different mail clients, checked by hand once and recorded. The generated message identifier is stored before the first attempt and reused on retry, asserted in the attempt log. An injected timeout followed by a retry leaves exactly one message in the destination mailbox, tested against a real second account. An oversized attachment is refused when the message is scheduled, with the limit named in the message. Do not build yet: No return-to-inbox, sync or status page yet. ### Phase 4 · Bringing a thread back, only when it should come back Build: Track threads with a return time and a condition. Detect replies with incremental sync from a stored cursor, handling an expired cursor explicitly with a bounded full listing and recording that it happened. Resurfacing applies a label or moves the thread, and is idempotent. Trap: 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. Done when: An expired sync cursor triggers a bounded full listing and writes a fallback reason, tested with a stub returning the documented failure. A thread whose condition is 'only if nobody replied' does not resurface after a reply arrives, tested end to end with a second account. Resurfacing the same thread twice produces one label and one notification, tested. The sync cursor survives a restart and no reply is processed twice. Do not build yet: No status page, export or scope-change process yet. ### Phase 5 · The day it stops working, and the day you share it Build: Build a status page showing grant health, queue depth, the oldest pending message and the last successful send. Export every pending message as a standalone mail file plus a CSV of its schedule, so the queue can be opened in any mail client. On shutdown, print what is pending and when it was due. Document the scope in one place that a test compares against the code. Trap: 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. Done when: Export writes each pending message as a file a mail client opens, with its scheduled time in the accompanying CSV, tested by opening one. The status page shows grant health, queue depth, oldest pending and last successful send, and a stopped tick is visible within one interval. Stopping the service prints the pending list with deadlines rather than exiting silently. A test fails if the scope list in the code differs from the one documented in the README. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, team routing and delegated sending, which need an authorisation model per person and an audit trail before they are safe. - Open and click tracking, which means embedding a beacon in other people's mail without telling them and is the part of this category worth leaving out. - Publishing this for other people, which turns the scope question into a verification process and an annual assessment rather than a preference. - Inbox pause, which works by moving mail around under the provider's own rules and fails in ways that lose messages rather than delaying them. ### README must contain - That a testing-mode external OAuth client is issued a refresh token expiring in seven days, quoted from the provider's documentation with the date checked, and that this is why the front page shows the token's age - That the mail API has no scheduled-send endpoint, so this process is the thing that has to be awake - That the message identifier is generated before the first attempt and reused, and why that prevents a duplicate after a timeout - That the scope chosen here can send but not read bodies, and what changing it would cost - How to export the pending queue as mail files, and what shutdown prints
# Agent instructions · Boomerang for Gmail indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with TypeScript on a machine that stays awake, queue in SQLite, Your own OAuth client, the narrowest scope that does the job, Compose the message yourself and call the send method at the target time, Claim with a lease, and generate the message identifier before the first attempt, Authorisation health probed on a timer and shown on the front page. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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 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. - 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. - 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.
# Build plan · Boomerang for Gmail 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 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. ### Steps 1. Ask for the narrowest scope that can send Reading mail bodies is a different classification and a different future. Files: `src/auth/scopes.ts`, `README.md` 2. Store the publishing status beside the token It is what determines how long the grant is going to live. Files: `src/auth/store.ts` 3. Probe on a timer and surface it on the front page A health check nobody opens is a health check that does not exist. Files: `src/health.ts`, `src/views/home.tsx` ### Done when - [ ] The front page shows grant state and token age - [ ] A revoked grant marks future messages at risk within one interval - [ ] Scopes match across code, consent screen and README - [ ] Probe failures record their detail ### 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. ## Phase 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. ### Steps 1. Store the instant and the zone, not just the instant Nine in the morning is a local fact and offsets move. Files: `src/schedule.ts`, `tests/dst.test.ts` 2. Ask for the catch-up policy at scheduling time Nobody makes a good decision about it at eleven o'clock. Files: `src/schedule.ts`, `src/views/compose.tsx` 3. Claim with a lease before touching the network It is what makes a second tick harmless. Files: `src/queue.ts`, `tests/once.test.ts` ### Done when - [ ] A missed send follows its stored catch-up policy - [ ] A send across a transition lands at the right local time - [ ] Concurrent ticks send once - [ ] Timeouts are recorded as attempts ### 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. ## Phase 3 · A message that threads, and never arrives twice A scheduled reply lands in the right conversation and an ambiguous failure produces one message. ### Steps 1. Generate the message identifier before the first attempt It is the only handle you have after an ambiguous failure. Files: `src/compose.ts`, `tests/idempotent.test.ts` 2. Set in-reply-to and references from the thread Subject matching is not threading, whatever your own client shows. Files: `src/compose.ts` 3. Refuse oversized attachments at scheduling time Not at send time, which is when nobody is looking. Files: `src/validate.ts` ### Done when - [ ] A scheduled reply threads in two clients - [ ] The identifier is generated once and reused on retry - [ ] A timeout plus retry delivers one message - [ ] Oversized attachments are refused when scheduled ### 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. ## Phase 4 · Bringing a thread back, only when it should come back A thread resurfaces on time, and not at all if somebody already replied. ### Steps 1. Handle the expired cursor as its own case It is neither a crash nor an empty result. Files: `src/sync.ts`, `tests/sync-expiry.test.ts` 2. Make resurfacing idempotent The tick will run twice eventually and the thread should not notice. Files: `src/boomerang.ts` 3. Check the reply condition against the thread, not the inbox A reply on another thread is not a reply on this one. Files: `src/boomerang.ts`, `tests/condition.test.ts` ### Done when - [ ] An expired cursor causes a recorded full listing - [ ] A replied thread does not resurface - [ ] Resurfacing twice changes nothing the second time - [ ] The cursor survives a restart without reprocessing ### 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. ## Not in this build - Shared inboxes and delegated sending. - Open and click tracking. - Publishing this for other people. - Inbox pause. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The queue, the drafts, the attempts and the health log. DATABASE_PATH=./data/boomerang.db # Required. Your own OAuth client, not a shared one. GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. The narrowest scope that sends. A test compares it against the README. GOOGLE_SCOPES=https://www.googleapis.com/auth/gmail.send # Required · secret. The refresh token. Its age is shown on the front page. TOKEN_PATH=./data/token.json # Required. How quickly a dead grant is noticed, which is the whole alarm. PROBE_INTERVAL_MINUTES=30 # Required. Applied when the sender does not choose. Late is not always better than never. DEFAULT_CATCH_UP_POLICY=skip-and-notify # Required. The zone scheduling times are entered in. Stored per message as well. SENDER_ZONE=Europe/London
# Boomerang for Gmail · product brief ## Problem The visible Gmail scheduling add-on loop is buildable, but a credible replacement needs more than the first screen. Boomerang for Gmail earns its keep through deliverability, security, integration, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A send-later tool that never quietly misses a message: the grant's health is on the front page, a missed time follows a policy chosen in advance, a retry after a timeout delivers one copy, and the whole queue exports as files any mail client can open. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - IMAP or Gmail OAuth credentials where supported - secure local keychain access ## Explicit non-goals for v1 - 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 ## Success criteria - The front page is confirmed to show grant state and token age - A missed send time is confirmed to follow its stored catch-up policy - A timeout followed by a retry is confirmed to deliver one message - An expired sync cursor is confirmed to fall back and record the reason - The pending queue is confirmed to export as openable mail files
# Build brief · Boomerang for Gmail The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal Gmail companion that sends messages later, brings threads back, and shouts before its authorisation dies to replace Boomerang for Gmail. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript on a machine that stays awake, queue in SQLite · a scheduler asleep at eight in the morning has not scheduled anything - Authorisation: Your own OAuth client, the narrowest scope that does the job · the scope you pick decides whether a third-party security assessment is ever in your future - Sending: Compose the message yourself and call the send method at the target time · the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do - Queue: Claim with a lease, and generate the message identifier before the first attempt · a retry after an ambiguous failure must not put two copies in somebody's inbox - Alarms: Authorisation health probed on a timer and shown on the front page · a silent authorisation failure turns a promise into a message that simply never arrives ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authorisation that tells you before it dies Build: Create your own OAuth client and request the narrowest scope that can send. Store the client's publishing status and the time the refresh token was obtained. Probe the grant on a timer, record every probe, and put the state and the token's age on the front page and in a health endpoint. Trap: 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. Done when: The front page shows the publishing status, the age of the refresh token and the time of the last successful probe. A grant revoked at the provider is detected within one probe interval and every future scheduled message is marked at risk rather than left quietly pending, tested against a stub returning an authorisation failure. The scope list in the code, the consent screen and the README are identical, asserted by a test that reads all three. A failed probe is recorded with its detail, not just a boolean. Do not build yet: No queue, sending, threading or return-to-inbox yet. ### Phase 2 · A promise that is kept or broken loudly Build: Schedule in the sender's zone, storing both the instant and the zone. Ask for a catch-up policy when the message is scheduled: send late with a note, or skip and tell me. The tick claims due messages with a lease before doing anything, and records every attempt including the timeouts. Trap: 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. Done when: A message whose send time passed while the process was stopped follows its stored policy, tested by stopping the clock and restarting. A message scheduled across a daylight saving change sends at the intended local time, tested at both transitions. Two ticks running at once send a due message exactly once, tested. Every attempt including timeouts appears in the attempt table. Do not build yet: No composing, threading, return-to-inbox or status page yet. ### Phase 3 · A message that threads, and never arrives twice Build: Compose the message yourself with a message identifier you generate before the first attempt. For a reply, set the in-reply-to and references headers from the thread being answered. Send through the send method and store the returned identifiers. Refuse an attachment above a stated size at scheduling time, checking the provider's current published limit rather than guessing one. Trap: 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. Done when: A scheduled reply appears inside the original conversation in two different mail clients, checked by hand once and recorded. The generated message identifier is stored before the first attempt and reused on retry, asserted in the attempt log. An injected timeout followed by a retry leaves exactly one message in the destination mailbox, tested against a real second account. An oversized attachment is refused when the message is scheduled, with the limit named in the message. Do not build yet: No return-to-inbox, sync or status page yet. ### Phase 4 · Bringing a thread back, only when it should come back Build: Track threads with a return time and a condition. Detect replies with incremental sync from a stored cursor, handling an expired cursor explicitly with a bounded full listing and recording that it happened. Resurfacing applies a label or moves the thread, and is idempotent. Trap: 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. Done when: An expired sync cursor triggers a bounded full listing and writes a fallback reason, tested with a stub returning the documented failure. A thread whose condition is 'only if nobody replied' does not resurface after a reply arrives, tested end to end with a second account. Resurfacing the same thread twice produces one label and one notification, tested. The sync cursor survives a restart and no reply is processed twice. Do not build yet: No status page, export or scope-change process yet. ### Phase 5 · The day it stops working, and the day you share it Build: Build a status page showing grant health, queue depth, the oldest pending message and the last successful send. Export every pending message as a standalone mail file plus a CSV of its schedule, so the queue can be opened in any mail client. On shutdown, print what is pending and when it was due. Document the scope in one place that a test compares against the code. Trap: 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. Done when: Export writes each pending message as a file a mail client opens, with its scheduled time in the accompanying CSV, tested by opening one. The status page shows grant health, queue depth, oldest pending and last successful send, and a stopped tick is visible within one interval. Stopping the service prints the pending list with deadlines rather than exiting silently. A test fails if the scope list in the code differs from the one documented in the README. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, team routing and delegated sending, which need an authorisation model per person and an audit trail before they are safe. - Open and click tracking, which means embedding a beacon in other people's mail without telling them and is the part of this category worth leaving out. - Publishing this for other people, which turns the scope question into a verification process and an annual assessment rather than a preference. - Inbox pause, which works by moving mail around under the provider's own rules and fails in ways that lose messages rather than delaying them. ### README must contain - That a testing-mode external OAuth client is issued a refresh token expiring in seven days, quoted from the provider's documentation with the date checked, and that this is why the front page shows the token's age - That the mail API has no scheduled-send endpoint, so this process is the thing that has to be awake - That the message identifier is generated before the first attempt and reused, and why that prevents a duplicate after a timeout - That the scope chosen here can send but not read bodies, and what changing it would cost - How to export the pending queue as mail files, and what shutdown prints
# Architecture · Boomerang for Gmail ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with TypeScript on a machine that stays awake, queue in SQLite | a scheduler asleep at eight in the morning has not scheduled anything | | Authorisation | Your own OAuth client, the narrowest scope that does the job | the scope you pick decides whether a third-party security assessment is ever in your future | | Sending | Compose the message yourself and call the send method at the target time | the mail API has no scheduled-send endpoint, so the waiting is entirely yours to do | | Queue | Claim with a lease, and generate the message identifier before the first attempt | a retry after an ambiguous failure must not put two copies in somebody's inbox | | Alarms | Authorisation health probed on a timer and shown on the front page | a silent authorisation failure turns a promise into a message that simply never arrives | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Authorisation | Scopes, token storage and probing. | Any provider, provided the grant's health is probed rather than assumed. | | Queue | Claims, leases, catch-up policy and attempts. | Any durable store with a conditional update. Not a timer over an in-memory list. | | Compose | Headers, identifiers and threading. | Any builder, provided the identifier is generated before the first attempt. | | Sync | The cursor, expiry handling and reply detection. | Any mechanism, provided an expired cursor is its own handled case. | | Status | Health, queue depth and export. | Any presentation, provided the oldest pending message is visible. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · The queue, the drafts, the attempts and the health log. - `GOOGLE_CLIENT_ID` · required · Your own OAuth client, not a shared one. - `GOOGLE_CLIENT_SECRET` · required, secret · Paired with the client id. Never in the repository. - `GOOGLE_SCOPES` · required · The narrowest scope that sends. A test compares it against the README. - `TOKEN_PATH` · required, secret · The refresh token. Its age is shown on the front page. - `PROBE_INTERVAL_MINUTES` · required · How quickly a dead grant is noticed, which is the whole alarm. - `DEFAULT_CATCH_UP_POLICY` · required · Applied when the sender does not choose. Late is not always better than never. - `SENDER_ZONE` · required · The zone scheduling times are entered in. Stored per message as well. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Boomerang for Gmail product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node with TypeScript on a machine that stays awake, queue in SQLite, Your own OAuth client, the narrowest scope that does the job, Compose the message yourself and call the send method at the target time, Claim with a lease, and generate the message identifier before the first attempt, Authorisation health probed on a timer and shown on the front page. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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 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. - 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. - 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. - 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.
# Delivery milestones · Boomerang for Gmail Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Authorisation that tells you before it dies The front page knows the state of the grant and how old it is, without being asked. ### Steps 1. Ask for the narrowest scope that can send Reading mail bodies is a different classification and a different future. Files: `src/auth/scopes.ts`, `README.md` 2. Store the publishing status beside the token It is what determines how long the grant is going to live. Files: `src/auth/store.ts` 3. Probe on a timer and surface it on the front page A health check nobody opens is a health check that does not exist. Files: `src/health.ts`, `src/views/home.tsx` ### Done when - [ ] The front page shows grant state and token age - [ ] A revoked grant marks future messages at risk within one interval - [ ] Scopes match across code, consent screen and README - [ ] Probe failures record their detail ### 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. ## M2 · A promise that is kept or broken loudly A message scheduled for a time the machine was asleep does what its stored policy says. ### Steps 1. Store the instant and the zone, not just the instant Nine in the morning is a local fact and offsets move. Files: `src/schedule.ts`, `tests/dst.test.ts` 2. Ask for the catch-up policy at scheduling time Nobody makes a good decision about it at eleven o'clock. Files: `src/schedule.ts`, `src/views/compose.tsx` 3. Claim with a lease before touching the network It is what makes a second tick harmless. Files: `src/queue.ts`, `tests/once.test.ts` ### Done when - [ ] A missed send follows its stored catch-up policy - [ ] A send across a transition lands at the right local time - [ ] Concurrent ticks send once - [ ] Timeouts are recorded as attempts ### 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. ## M3 · A message that threads, and never arrives twice A scheduled reply lands in the right conversation and an ambiguous failure produces one message. ### Steps 1. Generate the message identifier before the first attempt It is the only handle you have after an ambiguous failure. Files: `src/compose.ts`, `tests/idempotent.test.ts` 2. Set in-reply-to and references from the thread Subject matching is not threading, whatever your own client shows. Files: `src/compose.ts` 3. Refuse oversized attachments at scheduling time Not at send time, which is when nobody is looking. Files: `src/validate.ts` ### Done when - [ ] A scheduled reply threads in two clients - [ ] The identifier is generated once and reused on retry - [ ] A timeout plus retry delivers one message - [ ] Oversized attachments are refused when scheduled ### 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. ## M4 · Bringing a thread back, only when it should come back A thread resurfaces on time, and not at all if somebody already replied. ### Steps 1. Handle the expired cursor as its own case It is neither a crash nor an empty result. Files: `src/sync.ts`, `tests/sync-expiry.test.ts` 2. Make resurfacing idempotent The tick will run twice eventually and the thread should not notice. Files: `src/boomerang.ts` 3. Check the reply condition against the thread, not the inbox A reply on another thread is not a reply on this one. Files: `src/boomerang.ts`, `tests/condition.test.ts` ### Done when - [ ] An expired cursor causes a recorded full listing - [ ] A replied thread does not resurface - [ ] Resurfacing twice changes nothing the second time - [ ] The cursor survives a restart without reprocessing ### 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. ## M5 · The day it stops working, and the day you share it (production only) Nothing is trapped in the queue and a scope change is a decision rather than an import. ### Steps 1. Export the queue as files a mail client can open So nothing anyone wrote is trapped in your database. Files: `src/export.ts`, `tests/export.test.ts` 2. Show oldest pending and last successful send together The gap between the two is the whole health of the thing. Files: `src/views/status.tsx` 3. Pin the scope list and test it against the README A scope change has consequences beyond the code. Files: `src/auth/scopes.ts`, `tests/scopes.test.ts` ### Done when - [ ] Pending messages export as openable mail files - [ ] The status page shows the oldest pending and last send - [ ] Shutdown prints pending messages and deadlines - [ ] A scope drift between code and README fails a test ### Watch 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.
# Operations · Boomerang for Gmail ## Backup The database, several times a day rather than nightly, because a day's loss here is a set of promises nobody knows were made. The draft files beside it; they are the messages themselves. ## Restore Restore, then check for rows stuck in the claimed state with a live lease before starting the tick, and check the health probe before letting anything send. A restore that resumes with a dead grant will mark the whole queue at risk, which is the correct outcome and should not be suppressed. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Grant state and token age, oldest pending message, time since the last successful send, sync fallback count and attempt outcomes. The oldest pending message is the single number that catches almost everything. ## Incident checklist The incident is a message that did not arrive or arrived twice. The attempt log with its generated identifier tells you which, the health log tells you whether the grant was alive at the time, and the sync fallback record explains any gap in reply detection. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The front page is confirmed to show grant state and token age - [ ] A missed send time is confirmed to follow its stored catch-up policy - [ ] A timeout followed by a retry is confirmed to deliver one message - [ ] An expired sync cursor is confirmed to fall back and record the reason - [ ] The pending queue is confirmed to export as openable mail files ## Launch constraint Do not market omitted Boomerang for Gmail capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The queue, the drafts, the attempts and the health log. DATABASE_PATH=./data/boomerang.db # Required. Your own OAuth client, not a shared one. GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. The narrowest scope that sends. A test compares it against the README. GOOGLE_SCOPES=https://www.googleapis.com/auth/gmail.send # Required · secret. The refresh token. Its age is shown on the front page. TOKEN_PATH=./data/token.json # Required. How quickly a dead grant is noticed, which is the whole alarm. PROBE_INTERVAL_MINUTES=30 # Required. Applied when the sender does not choose. Late is not always better than never. DEFAULT_CATCH_UP_POLICY=skip-and-notify # Required. The zone scheduling times are entered in. Stored per message as well. SENDER_ZONE=Europe/London
$ choose a build depth, inspect the files, then open the complete pack in your agent
Boomerang for Gmail: Email is infrastructure. Customers pay to avoid lost messages, broken auth, spam problems, and the reputational cost of a flaky inbox.
xteam routing and shared inbox rules
xabuse response and security operations
xcalendar-provider edge cases and timezone correctness
xprovider-grade deliverability
xpush reliability across devices
Boomerang for Gmail pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| basic | $0/user | $0/user | 10 message credits/month; each message using Send Later, Boomerang, Response Tracking, or Read Receipts consumes a credit |
| personal | — | $4.98/user | Unlimited message credits; Gmail addresses only; includes mobile apps |
| pro | — | $14.98/user | Unlimited message credits; Gmail or Google Workspace; recurring messages and advanced Inbox Pause/Respondable |
| premium | — | $49.98/user | Unlimited message credits; adds track-by-default, CRM integration, Toolbox, priority support, and GQueues Business |
| teams pro | custom | — | Central billing/admin; instant quote for up to 100 users; 100+ users require sales contact |
| teams premium | custom | — | Central billing/admin; instant quote for up to 100 users; 100+ users require sales contact |
| enterprise | custom | — | Custom service agreement, training, professional services, and integrations |
free tier10 message credits/month; credits count messages using Send Later, Boomerang, Response Tracking, or Read Receipts; 30-day Pro trial with no card
billingmonthly + annual; public individual prices show annual equivalents, while exact month-to-month prices are available only during account checkout
hidden costsPersonal is restricted to @gmail.com addresses; Google Workspace accounts require Pro or higher. Paid access applies per subscribed email address. Team pricing is quantity-dependent.
verified 2026-08-12 · source ↗
Vibecode Boomerang for Gmail
Kinda. The core of Boomerang for Gmail is buildable in a weekend with the prompt on this page, but there are real gaps: team routing and shared inbox rules, abuse response and security operations. Read the honest list above before committing.
How much does Boomerang for Gmail cost?
Boomerang for Gmail's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Boomerang for Gmail?
Honestly: 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. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Boomerang for Gmail?
Yes: Mailspring (Open-source desktop mail client and useful IMAP prior art.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.