componentsfix(mail): ship the modules 51bc228ab left behind, and stop mailing malformed MIME
51bc228ab committed the google.ts send-path changes but not the two modules they
import, so HEAD does not compile. This adds them, plus the rest of the fix they
were part of.
Cedar builds its own RFC822 and hands it to gmail.messages.send, so every shaping
decision Gmail's composer would have made is ours. Four were wrong, and together
they were enough for Microsoft EOP to silently quarantine a legitimate reply — no
bounce, a SENT label, and "Email created and sent successfully" in our own logs.
Sam MacGregor's 2026-09-10 reply to Theresa Keown never arrived; she wrote back two
days later asking if she had the wrong address for him. She did not.
- text/plain alternative derived from the HTML, on the send AND draft paths
- non-X- custom headers refused at the driver boundary; the composer's bare
Thread-Id becomes X-Cedar-Thread-Id
- RFC 2047 encoded-words undone where the payload is plain ASCII (mimetext
base64-wraps every Subject and display name with no way to opt out)
- quoted inline images resolved from /api/inline-image reference urls to real cid:
attachments, so a root-relative url and a connection uuid stop going on the wire
- X-Mailer: Cedar, so an audit can attribute a message instead of fingerprinting it
The fifth signal, duplicate Received headers, survives a fully corrected message and
is stamped by Gmail on API injection — detected and reported, but not ours.
audit-outbound-mime.ts walks real SENT mail through the same detector the tests and
the live verification assert on. Across 59 accounts / 3158 messages, Thread-Id is the
only non-standard header present anywhere: 52 messages, 10 accounts.
headless-send-verify gains assertions F-I against messages.get format=raw, and its
cleanup now trashes by thread id via modifyLabels. It previously called
users.messages.delete, which needs the full mail.google.com scope Cedar does not
request — the old comment blaming a missing gmail.modify scope was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 13, 2026, 1:12 PM3 defectshooksfix: close the six audit findings on the send/telemetry work
A — the relay trusted the client to redact. It now enforces its own denylist on
normalized keys and drops email-shaped strings, so a mistake in one browser build
cannot put a recipient or a subject into Axiom.
B — teardown and keepalive were one flag. A send carrying attachments is too large
for keepalive, so it fell through to awaiting the session gate while the page was
going away, which is exactly how the request got lost. They are now separate:
every teardown commit skips the gate, keepalive stays size-limited.
C — the volume caps could silence the one event an incident needs. The sampler
already refused to drop a send or a failure, but the session cap and the buffer
eviction knew nothing about that, so a long-lived tab went quiet for everything
after 2,000 events and overflow evicted oldest-first regardless. Sends and failures
are now exempt from the session cap and evicted last. sanitizeEvent carries the
flag onto the buffered copy — without that the eviction loop read undefined on
every entry and the exemption did nothing, which the new test is what caught.
D — findOrCreateEmailEvent (singular) never wrote rfc_message_id, so events created
through that path stayed invisible to the RFC dedupe added in 51bc228ab.
E — the client request id was a timestamp plus a counter that resets on page load.
Two tabs, or a reload inside the same millisecond, minted identical ids and the
join then matched unrelated requests. It is a uuid now.
F — residue from the 22-row phantom cleanup. Two analytics_draft_actions rows still
pointed at messages Gmail 404s; the table has no FK, so nothing cascaded and nothing
dangled visibly, but attribution-gap.ts matches drafts to sends by message_id and
the fallback only runs when that column is NULL. Both real Cedar-drafted sends were
being reported as unattributed. Repointed to the surviving message, verified against
live Gmail, before-state kept. The one agent_executions row with a NULL event_id is
deliberately left alone: the run was triggered by the phantom, and repointing it at
a different message would invent causation.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 14, 2026, 6:39 AMstorefeat(inbox): unibox keyboard parity, undo-send that puts the draft back, sharper chat context
Unibox now navigates like the email list — j/k, Enter, Escape all walk whichever
list is on screen via a new `activeListSource` on the thread slice, and Escape
clears the full selection (focused row, bulk checkboxes, ambient conversation)
instead of only the keyboard cursor.
Undo send stops round-tripping through localStorage and a URL rewrite: the send
records where the draft came from and undo reopens that surface, restoring the
un-quoted body onto the draft row (recreating it when the send removed it).
Chat context: an auto-committed primary conversation is provisional while the
chat is empty and drops when you move on, the context cards follow the deal
you're actually on, threads with no conversation get a link prompt, and the
conversation surface shows a keyboard legend rather than restating cards already
on screen.
Also: agenda Trash removes the row outright, Next steps renders as one card with
its booked calls beneath, and channel chats focus the composer on open.Jul 30, 2026, 11:54 PMutilsfix(drafts): four different failures shared one sentence
Every draft-save failure in prod said "Failed to save draft". Among
them was invalid_grant — a dead Google token, where retrying is the one
thing that cannot work. The message sent the user back to the keyboard
to do it anyway.
Classify the cause and say it. A dead token names itself and carries a
Reconnect action; a provider fault says the text is safe rather than
implying the user should retype; a transport failure says so. The toast
also carries a short trace ref, so "it said X" becomes a lookup in
Axiom instead of a search across four identical strings.
(design: bug-draft-saving-duplicates-2026-09-01 phase 4)
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 11:45 PMBUGREPORT-draft-send-failed-toast.mddocs: add investigation reports and bug writeups
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 21, 2026, 7:43 PMBUGREPORT-duplicate-draft-on-type.mddocs: add investigation reports and bug writeups
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 21, 2026, 7:43 PMBUGREPORT-optimistic-timeline-desync.mdfix(mail): stop losing deferred sends, and stop recording unsent mail as sent
Traced from a report that an email sent in Cedar never reached Gmail while the
deal timeline showed it as sent. Four defects, each independently reproduced.
Undo-send dropped failures. Of the three paths that commit a held-back send,
only the 5s timer reported one; force-send and page teardown discarded the
promise with `.catch(() => {})`, leaving the optimistic writes standing over an
email that never went out. All three now dispatch through one commitPendingSend.
The teardown guard was unreachable. It lived in the composer, which unmounts the
instant a send is deferred, so for almost the whole undo window nothing watched
for the tab closing. Moved to module scope beside pendingSend, and extended to
every pending send rather than only ones carrying attachments.
Keepalive never applied to a batched send. The guard matched the whole pathname
against '/mail.send', but httpBatchLink puts a comma-joined procedure list there.
Ingestion recorded unsent mail as sent. Gmail's pub/sub sync fires ~1s after a
change, fast enough to catch a message mid-send: not a draft, so the only state
check let it through, and direction was inferred from the sender address alone.
Dedupe keyed on Gmail's internal messageId — the one id that differs between the
transient and committed copies — so the same email was written twice. Now gated
on Gmail's SENT label and deduped on the RFC Message-ID, whose column and
partial index already existed for this purpose.
Threading depended on which surface composed the reply. The composer only built
In-Reply-To/References when it held a replyToMessage, so a reply written from
the conversation surface shipped with none and Gmail opened a new conversation.
parseOutgoing now derives the chain from the threadId via the existing
deriveThreadingHeaders, covering both send paths regardless of caller.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 12, 2026, 11:36 AMDESIGN-duplicate-draft-reconciliation.mdwip: snapshot in-flight work across agenda, brain, conversations and chat
Pre-existing uncommitted work from concurrent sessions, snapshotted so the
agenda Current|Future restructure lands as one commit per design-doc phase.
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 21, 2026, 2:29 PMDESIGN-userEdited-drafts.mdtest: race coverage + retire old design doc (design: userEdited-drafts phase 4)
thread-data-sync-race.test.tsx now exercises the userEdited rule: locally-
edited drafts survive interleaved foreground + prefetch mail.get arrivals.
The old generation-tracker-based "stale save callback" assertion is replaced
with a pure store-level interleaving test, since the tracker no longer exists.
Old design doc at docs/design/draft-ownership-and-mail-get-reconciliation.md
is superseded by apps/mail/modules/drafting/DESIGN-userEdited-drafts.md and
deleted.
Co-Authored-By: Claude Opus 4.7 (1M context) <<email>>May 20, 2026, 12:38 AMDRAFT_SESSION_ARCHITECTURE.mdfeat: [threads] new stable draft sessionid systemJan 11, 2026, 5:09 PMdrafting.mdfeat: [threads] drafts now use sessionDraftIdJan 11, 2026, 4:58 PMindex.tsbuild(deps): resolve the three dependency skews behind the last typecheck errors
zod: the root declared zod 4.1.1 purely to satisfy zod-to-json-schema, which
hoisted v4 into node_modules/zod. @hookform/resolvers has no zod dependency
of its own, so its .d.ts resolved that v4 while apps/mail builds schemas with
catalog v3 — every zodResolver call was a type error. Root now tracks the
catalog, and a packageExtension pins resolvers to v3 explicitly so this can't
drift back in on a hoist order change.
react-router: @react-router/dev was a minor behind the runtime and emitted
GetAnnotations<Info, false> into .react-router/types, against a one-parameter
GetAnnotations. Bumped the generator to match.
tiptap: `novel` pins TipTap v2, so defaultExtensions was feeding v2 extension
classes into a v3 editor — a type error over a real runtime hazard. Every
consumer of novel's React surface (EditorBubble, its AI bubble menu, the slash
command items) was already dead code, so this drops to a straight swap of the
extension classes for their v3 equivalents, and novel and its patch go with
it. AIHighlight, CharacterCount and CustomKeymap are dropped — novel-only, and
unreferenced outside extensions.ts. Underline now comes from StarterKit, which
bundles it in v3.
Also declares @testing-library/user-event, which email-composer-send.test.tsx
imports but nothing ever depended on, and fixes that suite's wrong relative
mock paths. It still doesn't run — its jest.mock factories close over
out-of-scope variables — but that is a test rewrite, not a type fix.
apps/mail: 23 -> 0. `pnpm types` is green across the workspace. Test results
are unchanged (155/165 suites, same 23 pre-existing failures) and
`react-router build` succeeds.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 2, 2026, 8:10 PM