Skip to content

Persist undo-send in the main process - #194

Draft
mickn wants to merge 7 commits into
ankitvgupta:mainfrom
mickn:claude/undo-send-persist-upstream
Draft

mickn wants to merge 7 commits into
ankitvgupta:mainfrom
mickn:claude/undo-send-persist-upstream

Conversation

@mickn

@mickn mickn commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

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_messages and runs through the existing main-process send-later scheduler.

  • The scheduler wakes at the next due time, so a 15-second undo delay is not held up by the old 30-second polling interval.
  • Startup recovery sends overdue rows, and quit flushes pending sends before the database closes.
  • An atomic claim makes cancel and send mutually exclusive when they race.
  • The renderer toast only displays the countdown and sends cancellation requests over IPC.

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.json
  • npx tsc --noEmit -p tsconfig.web.json
  • 1,514 unit tests
  • 8/8 tests/e2e/undo-send.spec.ts
  • npm run build

The 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-pr suite have not been run, so this remains a draft.

mickn and others added 3 commits August 5, 2026 11:26
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
mickn marked this pull request as ready for review August 5, 2026 16:02

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment thread src/main/services/scheduled-send-service.ts Outdated
Comment thread src/main/index.ts Outdated
Comment thread src/renderer/App.tsx
Comment thread src/renderer/components/EmailDetail.tsx
@mickn
mickn marked this pull request as draft August 6, 2026 18:54
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