BUGREPORT-draft-send-failed-toast.md8.2 KBView on GitHub # Bug Report — "Failed to send" toast fires before the undo timer, then send succeeds but the draft is not deleted
## Symptom & impact
When <email> sends a draft (reply) with undo-send enabled:
1. A red "Failed to send" toast appears **before** the 5-second undo countdown finishes.
2. The countdown finishes and the email **does** send correctly.
3. The original draft is **not** removed from the UI/thread, so jesse has to delete it manually.
Net effect: every send looks like it failed, yet succeeds, and leaves a duplicate draft behind.
## Root cause (one paragraph)
In `email-composer.tsx`, `proceedWithSend()` wraps the **entire** flow — the optimistic
send, `startUndoableSend()` (which schedules the real send 5s later), **and** the
post-deferral UI cleanup (`editor.clearContent()`, `form.reset()`, `setNewEmail(false)`,
`onSendSuccess?.()`) — in a single `try`. Once `startUndoableSend()` returns, the real
send is already committed to fire at T+5 via a module-level timer that survives unmount.
But if **anything after that line throws synchronously** (the cleanup block / the parent
`onSendSuccess` callback), control jumps to the `catch`, which (a) shows `toast.error('Failed
to send email')` **immediately at T+0** and (b) calls `rollback()` — undoing
`optimisticSendDraft` and **restoring the draft** into the Zustand store and React Query
cache. The 5s timer is unaffected, so at T+5 the deferred send runs and succeeds
server-side. The result is exactly the three symptoms: error at T+0, successful send at
T+5, draft restored and never re-cleared. The same `rollback()`-on-error pattern also
exists in the deferred `onSendError` path, and `onSendComplete` never invalidates the
thread cache, so nothing corrects the restored draft afterward.
## Code walkthrough (with evidence)
### Server side is healthy — the send itself works
jesse's reply sends complete cleanly. Local trace `62d28a750972d4db635424113200ce00`
(`cedar-local`, 2026-06-16 02:36):
```
google.sendDraft.replyDetected "Reply detected, using create() to maintain threading" draftId=r8533555404811160919
google.create.start
google.create.apiCall "Sending email via Gmail API"
google.create.success "Email created and sent successfully"
google.sendDraft.draftCleanup "Draft cleaned up after reply send" draftId=r8533555404811160919
```
- No `mail.sendDraft: Failed to send draft` for jesse in `cedar-prod` or `cedar-local` in the last 7 days.
- jesse's `cedar-prod` activity over the last 3 days is read-only (`mail.listThreads`, `mail.checkSync`) — **his send testing runs against his local server**, which is why prod has no send rows for him.
- The one prod `mail.sendDraft: Failed to send draft` in 14 days was a different user (<email>, trace `9b5304b3b81972bf9db7c57d7cc172b2`, "Requested entity was not found", total failure) — unrelated to jesse's "it still sends" symptom.
Conclusion: the "Failed to send" jesse sees is **client-side**, not a server send failure.
### Client side — the failure path
`apps/mail/modules/drafting/components/email-composer.tsx`
- [`proceedWithSend` try starts](apps/mail/modules/drafting/components/email-composer.tsx#L1043) at line 1043.
- [`startUndoableSend(...)` returns, scheduling the real send at T+5](apps/mail/modules/drafting/components/email-composer.tsx#L1381-L1440). The timer lives at module scope in `use-undo-send.ts` and survives composer unmount.
- [Post-deferral cleanup runs inside the same try](apps/mail/modules/drafting/components/email-composer.tsx#L1453-L1461):
```ts
editor.commands.clearContent(true);
form.reset();
setNewEmail(false);
onSendSuccess?.(); // parent callback — runs inside the send try/catch
```
- [The catch treats ANY throw as a send failure](apps/mail/modules/drafting/components/email-composer.tsx#L1462-L1487):
```ts
} catch (error) {
if (rollback) rollback(); // ← restores the draft (undoes optimisticSendDraft)
if (conversationRollback) conversationRollback();
posthog.capture('email_composer_send_error', { errorMessage, errorType, errorStack, ... });
toast.error('Failed to send email'); // ← shown at T+0, before the timer
}
```
`use-undo-send.ts`: [the deferred send fires at T+5 regardless](apps/mail/modules/drafting/hooks/use-undo-send.ts#L202-L217) — `sendFn()` then `onSendComplete`/`onSendError`. Because the timer was already registered before the throw, the send proceeds and succeeds.
### Why the draft is not deleted
- The `catch` `rollback()` (and the `onSendError` `rollback()` at
[L1399](apps/mail/modules/drafting/components/email-composer.tsx#L1399)) re-insert the
draft into the Zustand store and the `mail.get` cache.
- [`onSendComplete`](apps/mail/modules/drafting/components/email-composer.tsx#L1392-L1396)
(fires at T+5 on the successful send) does **not** invalidate `mail.get`, so the
restored draft is never cleared. This matches the documented gap in
`apps/server/src/docs/draft-send-sync-architecture.md` ("Secondary issue: no cache
invalidation after send completes").
## Confirmation
jesse confirmed the red toast appears **instantly on click, while the 5s "Sending… / Undo"
countdown is still running** — i.e. the catch-at-T+0 path, exactly as diagnosed. The exact
throwing statement in the cleanup block lives in the **PostHog `email_composer_send_error`
event** (`errorMessage` / `errorStack`), which this session's PostHog MCP can't query; the
fix is correct regardless of which line throws.
## Fix applied
`apps/mail/modules/drafting/components/email-composer.tsx`:
1. **Isolate post-send cleanup.** The cleanup block (`editor.clearContent()`, `form.reset()`,
`setNewEmail(false)`, `onSendSuccess?.()`) is now wrapped in its own `try/catch` that logs
and swallows. A cleanup/`onSendSuccess` throw can no longer fall through to the send's
`catch`, so it no longer calls `rollback()` (which restored the draft) or shows
"Failed to send email". Covers both the deferred and immediate-send paths.
2. **Invalidate the thread on completion.** `onSendComplete` now calls
`queryClient.invalidateQueries({ queryKey=[redacted] id: effectiveThreadId }) })`
so once the real send (and its server-side draft cleanup + S3 sync) finishes, a
restored/stale draft can't linger in the cache.
Typecheck: the mail package currently reports thousands of errors from the in-progress
`staging` merge / concurrent WIP in the working tree (including an unresolved `document.tsx`
conflict) — not from this change. The pre-existing errors in `email-composer.tsx` are at
lines 185, 432, 654, 1012; my edited lines (1392–1408, 1463–1479) introduce **no** new
type errors.
## Testing status — blocked by pre-existing harness rot
I could not land a running regression test. The component's Jest suite
(`__tests__/email-composer-send.test.tsx`) is **non-functional in this working tree**, for
reasons unrelated to this bug:
- babel-jest hoists `jest.mock` factories above imports → committed `React.forwardRef` in a
factory throws "out-of-scope variable" (needs `require('react')` inside the factory).
- mock paths are off by the `__tests__/` level (`../hooks/…` should be `../../hooks/…`,
`./X` should be `../X`).
- ESM `import { EmailComposer }` is hoisted above the `const mock…` declarations → TDZ
("Cannot access … before initialization"); needs `require()` after the mocks.
- component drift: the composer now pulls in `useSnippets`, calendar/dictation hooks, reads
`editor.state.doc`, renders multiple Send buttons, and uses tRPC routes the mock lacks.
A regression test (once the harness is revived) should assert, for a deferred send whose
post-send cleanup throws: (a) `toast.error('Failed to send email')` is NOT called, (b) the
`optimisticSendDraft` rollback is NOT called, and (c) on `onSendComplete`, `mail.get` is
invalidated for the thread.
## Blast radius
- Affects every undo-send send where the post-deferral cleanup or `onSendSuccess` throws — all composer entry points (thread reply, review mode, conversation timeline composer) share this `proceedWithSend`.
- The `rollback`-on-error + missing-invalidation combo is the same one described in `draft-send-sync-architecture.md`, so the "draft reappears" half is already a known, broader issue.