Skip to content

feat: add a persistent delayed-send queue with cancel for Gmail - #12

Closed
lucamzanon wants to merge 13 commits into
bulwarkmail:mainfrom
lucamzanon:feat/gmail-delayed-send
Closed

lucamzanon wants to merge 13 commits into
bulwarkmail:mainfrom
lucamzanon:feat/gmail-delayed-send

Conversation

@lucamzanon

Copy link
Copy Markdown

Stacked on #11 (feat/gmail-send-as-aliases); only the last commit is new.

What

Behind a new opt-in flag GMAIL_SCHEDULE_ENABLED=true, the Gmail backend gains a persistent delayed-send queue implementing RFC 4865 FUTURERELEASE, which is exactly what Bulwark's schedule send and undo send speak:

  • Session advertises maxDelayedSend (GMAIL_MAX_DELAYED_SEND, default 30 days) and submissionExtensions.FUTURERELEASE = ["HOLDFOR","HOLDUNTIL"].
  • A submission whose envelope carries HOLDFOR / HOLDUNTIL is validated exactly like an immediate send (identity re-read from Gmail, draft checked, envelope matched), then stored in SQLite (gmail_schedule: account, identity, recipients, thread, sha256 of the draft MIME, sendAt in UTC) and answered with undoStatus: pending + sendAt. Nothing is sent to Google at that point; the draft stays a native Gmail draft, which is the durable copy.
  • A worker (every 5 s, per-account write lock) leases due entries atomically and, right before drafts.send, re-checks composition, the identity (fresh send-as read), the draft's existence and hash, and the send ledger. Edited/deleted draft, removed identity, or an entry that came due while the bridge was down beyond GMAIL_SCHEDULE_LATE_TOLERANCE (default 900 s) → suspended, never sent: final, per-recipient deliveryStatus explains why, the draft remains in Drafts. Transient errors before the send call leave the entry pending for the next tick.
  • EmailSubmission/set update {undoStatus: "canceled"} cancels atomically while pending (cannotUnsend once being handed to Google or final); EmailSubmission/query lists newest first. Bulwark's reschedule creates the replacement before cancelling the original, so multiple pending entries per draft are allowed; first to send wins, the others become canceled (superseded).
  • An unconfirmed drafts.send marks the entry uncertain (delivered: unknown) and blocks further sends of that draft through the existing ledger, exactly like immediate sends. Entries left in sending by a crash are reconciled on startup via the ledger.
  • Filing patches (onSuccessUpdateEmail) on a held submission answer forbidden in the implicit Email/set: Gmail files the message when it is actually sent (the client treats this as a warning).
  • /healthz now returns per-status queue counters (no addresses or content).

With the flag off, behaviour is unchanged (maxDelayedSend: 0, HOLDFOR rejected).

Tests

  • New test/unit/gmail-schedule.spec.ts (10 tests): capability gating, HOLDFOR/HOLDUNTIL validation, queue without network, list/cancel/cannotUnsend, due send once + ledger, suspensions (edited/deleted draft, removed identity), transient retry, late tolerance, uncertain outcome, reschedule/supersede flow, restart reconciliation.
  • Gmail suite 101/101. Whole unit suite: 242 pass, 3 pre-existing failures unrelated to Gmail (refs.spec.ts, search.spec.ts, same on upstream).

🤖 Generated with Claude Code

lucamzanon and others added 13 commits September 11, 2026 08:34
Behind GMAIL_ALIASES_ENABLED. Reads users.settings.sendAs (covered by the
existing gmail.modify grant), offers the primary address plus accepted
aliases as read-only identities with stable ids, and lets drafts, MIME
imports and submissions use any of them. The identity is re-validated
against Gmail right before drafts.send; a removed alias yields
forbiddenFrom and keeps the draft. Signatures stay client-side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Behind GMAIL_SCHEDULE_ENABLED. HOLDFOR/HOLDUNTIL submissions are stored in
SQLite instead of being sent; a 5 s worker leases due entries atomically
and re-validates identity, draft hash and ledger before drafts.send.
Edited/deleted drafts, removed identities and entries that come due while
the bridge is down beyond the tolerance are suspended, never sent.
undoStatus=canceled cancels pending entries; EmailSubmission/query lists
them; interrupted sends are reconciled on restart; /healthz reports
per-status counters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lucamzanon

Copy link
Copy Markdown
Author

Superseded by #14, which consolidates this stack into one reviewable branch.

@lucamzanon lucamzanon closed this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant