Conversation
The undo-send delay was a setTimeout inside a React component (UndoSendToast.tsx), and window.api.compose.send() only ran when that timer fired. Until then the pending message existed solely in in-memory Zustand state — the main process knew nothing about it. Closing the window during the delay destroyed the renderer, the effect cleanup cleared the timer, and the message was silently lost: not in Sent, not in the outbox, not in drafts. Quit and crash lost it the same way. Move the authority for the delay into the main process, backed by the database. Rather than add a parallel service, extend the existing scheduler. Undo-send and send-later are the same mechanism — a persisted message with a due time that must fire reliably across close, quit, and crash. The two apparent differences were not architectural: the 30s poll was too coarse for a 15s undo (a defect for both callers, since send-later also fired up to 30s late), and the cancel behaviour is one policy field per row, not a second service. - Scheduler: fixed 30s setInterval -> next-due setTimeout clamped to 30s, re-armed on insert/cancel/reschedule. A 15s undo fires at 15s. - Schema: add attachments, kind, archive_thread_id, compose_context to scheduled_messages. - Race guard: claimScheduledMessage() is a conditional UPDATE, so exactly one of cancel and fire wins. A cancel that loses reports the message as sent instead of reopening compose. - Recovery: window close needs nothing (timers live in main); quit flushes pending sends via preventDefault -> flush -> quit with a 5s cap; crash is covered by recoverOnStartup() firing past-due rows. - Renderer: the toast is pure UI over main-process state. Optimistic email reconciliation moved to the sent broadcast. This also fixes two latent send-later bugs: attachments were silently dropped (no column, and insertScheduledMessage never handled them), and scheduled sends fired late with no startup recovery or quit flush. Migration is numbered 9, not 8: version 8 is already claimed by a concurrent branch (observed applied in a real DB on 2026-08-01). Reusing it would make this migration silently skip on any DB that ran the other 8, leaving the columns missing and every undo-send insert failing at runtime. The replay test's "no gaps" assertion is relaxed to "strictly increasing and unique" — a gap is legitimate across parallel branches; a duplicate is the dangerous case. Scope note: getScheduledMessageStats and both send-later list call sites now filter to kind='scheduled'. Without it, in-flight undo sends would appear in the send-later dropdown and inflate its badge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUzvMtEYh8TbaxGsB1UkJP
IpcResponse is a discriminated union where `data` is always present on the success variant. The redundant `|| !queued.data` check widened the type back out of the failure branch, so `.error` was no longer known to exist and tsc rejected it. Caught by CI, not locally: CI typechecks per-project via `tsc -p tsconfig.node.json` and `-p tsconfig.web.json`, while a bare `tsc --noEmit` uses the root config and misses these renderer files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUzvMtEYh8TbaxGsB1UkJP
The e2e suite (tests/e2e/undo-send.spec.ts) caught a real regression: after the delay elapsed the toast stayed visible indefinitely, failing `expect(undoToast).toBeHidden()`. Cause: moving the send into main made the toast's removal depend on the "sent" broadcast, but `scheduled-send:create` short-circuits in demo/test mode — it returns a synthetic row without inserting into the DB. With no row there is no scheduler tick, so nothing ever emitted "sent" and the toast waited on an event that could not arrive. In demo mode the message was also never delivered at all. Give the fake-data path its own timer map that reproduces the same lifecycle: fire a "sent" broadcast when the delay elapses, and let cancel clear the timer (returning the stored composeContext so Undo still restores the draft, and reporting cancelled=false when the timer already fired, matching the DB path's lost-the-race answer). Verified locally: 8/8 undo-send e2e pass, full e2e suite 344 passed / 10 skipped / 0 failed, 1443 unit pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUzvMtEYh8TbaxGsB1UkJP
mickn
marked this pull request as ready for review
August 5, 2026 16:02
mickn
marked this pull request as draft
August 6, 2026 18:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
Undo-send used a renderer-side timer. If the window closed, the app quit, or the process crashed during the delay, the message disappeared because it had never reached the main process or database.
How it works
Undo-send now stores the pending message in
scheduled_messagesand runs through the existing main-process send-later scheduler.The shared scheduler distinguishes undo-send from send-later so cancellation can reopen compose for one and create a Gmail draft for the other.
This also fixes two existing send-later bugs: attachments were not persisted, and scheduled messages could fire up to 30 seconds late. Undo-send rows are excluded from the send-later list and badge.
Migration v9 is intentional. Version 8 is already claimed by a concurrent branch, and reusing it could cause this migration to be skipped on databases that have applied the other v8.
Verification
npx tsc --noEmit -p tsconfig.node.jsonnpx tsc --noEmit -p tsconfig.web.jsontests/e2e/undo-send.spec.tsnpm run buildThe new persistence tests cover cancel/send races, startup recovery, quit flushing, timer precision, payload round-tripping, attachment persistence, and send-later UI scoping.
Real Gmail verification and the full
npm run pre-prsuite have not been run, so this remains a draft.