fix(mail): carry reply token in References + suppress self-echo (HT-49) - #52
Conversation
…-ID (HT-49)
Live production evidence (2026-07-17, first HT-44 run against real Gmail):
Gmail's users.messages.send accepted the engine's verbatim Message-ID but
replaced it on the wire with a Gmail-generated id. The customer's reply then
carried In-Reply-To/References pointing at Gmail's id, with our signed
reply token nowhere on the wire, so decideThreading correctly found no
verified token and forked a new conversation instead of appending.
Fix: sendReply now appends its own freshly-minted messageId as the FINAL
entry of the outbound References chain, after any ancestor ids. Gmail does
not rewrite References, and an RFC-5322-compliant reply's own References is
built as {original References} + {original Message-ID} — so the token rides
into the customer's reply one position before whatever foreign id the
provider substituted, exactly where decideThreading's existing newest-first
scan (src/mail/thread.ts, unmodified) finds it. In-Reply-To is untouched.
Specs updated in the same commit (mail semantics sacred, charter invariant
#5): threading.md §2a documents the mechanism and rationale; sending.md §4/§5
and agent-inbox-v1.md §4a document the derivation change. Tests: a MIME wire
test locking the token as the last References entry, a send.ts test for the
derived envelope, and an ingest.test.ts fixture reproducing tonight's exact
failure (foreign In-Reply-To, token mid-References) threading correctly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of the HT-49 References-token fix found a live-reproducible hole: putting a verifiable reply token in EVERY outbound reply's References means Gmail's own delivery of the SENT message back into its own mailbox (the self-echo the reconcile pipeline ingests like any other message) now carries that token too. isOwnMessageReflection's loop guard only checks the message's OWN Message-ID, which Gmail rewrites — so the guard never fires, decideThreading finds the token in References, and the agent's own reply gets appended a second time as a phantom inbound message, reopening a closed conversation. Fix: sendReply captures EmailSendResult.providerMessageId (Gmail's body.id — the same id gmail-reconcile.ts later reports for this exact message) and, via an optional SelfEchoGuardDeps (mailboxStore + inboundDeliveryStore), pre-seeds (mailboxId, providerMessageId) as an already-suppressed row in the inbound delivery ledger right after a successful send. When reconcile later lists that provider id, claim()'s existing "terminal row, do not double-process" branch absorbs it — zero changes to decideThreading, no heuristic on message content, and the customer-autoresponder case (which legitimately carries our token in References too) is untouched because the correlation is providerMessageId, not the token. Wired through attemptDeliveryOfClaimedThread/runDeliveryWorker (the retry path shares the same exposure) and the composition root (unconditional — every deployment here is Gmail-backed); absent everywhere else, so no other caller's behavior changes. New InboundDeliveryStore.preSuppressOwnSend never overwrites a row a genuine claim() already won — the one known residual race concedes to the pre-HT-49 failure mode rather than corrupting a real ingest. Specs updated in the same commit (mail semantics sacred, charter invariant #5): inbound-ingestion.md §5 documents the amendment and its residual race; threading.md §2a and sending.md cross-reference it; agent-inbox-v1.md §7 records the new optional InboxApiDeps.selfEchoGuard. Tests: an ingest.test.ts fixture reproducing the exact self-echo shape (From = mailbox address, foreign Message-ID, our token as the final References entry) now suppressed instead of appended; send.test.ts covers the guard's happy path, its three no-op conditions (absent guard, no providerMessageId, failed send), and inbound-deliveries.test.ts covers preSuppressOwnSend directly including the race-concession case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughHT-49 updates reply ChangesReply threading and self-echo suppression
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant AgentInboxAPI
participant sendReply
participant EmailSender
participant InboundDeliveryStore
participant ingestInboundMessage
AgentInboxAPI->>sendReply: submit reply with ancestor References
sendReply->>EmailSender: send with minted ID as final References entry
EmailSender-->>sendReply: providerMessageId
sendReply->>InboundDeliveryStore: preSuppressOwnSend(mailboxId, providerMessageId)
ingestInboundMessage->>InboundDeliveryStore: claim echoed provider message
InboundDeliveryStore-->>ingestInboundMessage: suppressed outcome
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@specs/api/agent-inbox-v1.md`:
- Around line 400-407: Revise the v1.1 description of InboxApiDeps.selfEchoGuard
to characterize suppression as best-effort rather than guaranteed. State that it
depends on a reported provider ID and mailbox match, and that reconciliation may
win the race as documented in inbound-ingestion.md §5; preserve the
absent-by-default and unchanged-deployment behavior.
In `@specs/mail/sending.md`:
- Around line 185-190: Update the “transmit References verbatim” contract in the
sendReply documentation to qualify that safe message-ID atoms preserve their
original order and values, while adapters may sanitize unsafe attacker-derived
ancestor IDs. Explicitly require the engine-minted final messageId token to
remain intact and preserve the existing Gmail MIME adapter security behavior.
In `@src/api/index.ts`:
- Around line 151-161: Update the documentation comment for selfEchoGuard in
sendReply to replace the absolute “never” claim with wording that normally
suppresses the sent-message echo while acknowledging the narrow race where
reconciliation claims the message before preSuppressOwnSend. Keep the existing
behavior and references to the self-echo guard unchanged.
In `@src/mail/send.ts`:
- Around line 103-171: Update the successful-send persistence flow around
setThreadDeliveryStatus and releaseThreadLease to durably mark the delivery as
completed using the provider receipt before any failure can leave it retryable.
Ensure persistence or lease-release errors cannot allow the delivery worker or
keyed replay to reclaim and resend an already-accepted message, while preserving
the original successful send outcome.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ef4b5c23-e276-4d3f-82d4-49eafbc4197d
📒 Files selected for processing (16)
specs/api/agent-inbox-v1.mdspecs/mail/inbound-ingestion.mdspecs/mail/sending.mdspecs/mail/threading.mdsrc/api/conversations.tssrc/api/index.test.tssrc/api/index.tssrc/composition/root.tssrc/mail/delivery-worker.tssrc/mail/ingest.test.tssrc/mail/send.test.tssrc/mail/send.tssrc/providers/adapters/gmail/mime.test.tssrc/providers/email-sender.tssrc/store/inbound-deliveries.test.tssrc/store/inbound-deliveries.ts
… exactly Three CodeRabbit findings, all the same thrust (docs overclaimed; code was right): (1) selfEchoGuard described as best-effort, not guaranteed, in the agent-inbox-v1 changelog and src/api/index.ts — the pre-seed runs after the provider send, so a fast reconcile can win the documented race and ingest that one echo (inbound-ingestion.md §5's conceded residual, now referenced from both). (2) sending.md §4's 'transmit References verbatim' contract qualified to match the shipped Gmail adapter: safe atoms verbatim and in order, unsafe attacker-derived ancestor atoms MAY be dropped (isSafeMsgId), and the engine-minted final token — safe by construction — must reach the wire intact. No code changed; comment-only in src/api/index.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
CodeRabbit review round resolved in 92f18d1 (docs/spec-only; no code behavior changed). Per-finding disposition:
Verification: 🤖 Generated with Claude Code |
Summary
Live production evidence (2026-07-17, first HT-44 run against real Gmail): Gmail's
users.messages.sendaccepted the engine's verbatimMessage-IDbut replaced it on the wire with a Gmail-generated id. The customer's reply then carriedIn-Reply-To/Referencespointing at Gmail's id, with our signed reply token nowhere on the wire — sodecideThreadingcorrectly found no verified token and forked a new conversation instead of appending. This is the failure this PR fixes, proven against real Gmail, not simulated.Fix (commit 1):
sendReplynow appends its own freshly-mintedmessageIdas the FINAL entry of the outboundReferenceschain, after any ancestor ids. Gmail does not rewriteReferences, and an RFC-5322-compliant reply's ownReferencesis built as{original References} + {original Message-ID}— so the token rides into the customer's reply one position before whatever foreign id the provider substituted, exactly wheredecideThreading's existing newest-first scan (src/mail/thread.ts, unmodified) finds it.In-Reply-Tois untouched.Review fix (commit 2): putting the token in every outbound reply's
Referenceshas a consequence: Gmail also delivers the SENT message's own copy back into the mailbox it was sent from (the "self-echo" the reconcile pipeline ingests like any other message), and that self-echo now carries the token too.isOwnMessageReflection's existing loop guard only checks the message's OWNMessage-ID, which Gmail rewrites — so the guard never fires,decideThreadingfinds the token inReferences, and the agent's own reply gets appended a second time as a phantom inbound message, reopening a closed conversation. Fixed one layer earlier, in the delivery ledger:sendReplycapturesEmailSendResult.providerMessageId(the same id reconcile later reports for this exact message) and, via an optionalSelfEchoGuardDeps, pre-seeds(mailboxId, providerMessageId)as an already-suppressed row in the inbound delivery ledger right after a successful send. Reconcile's existing "terminal row, do not double-process" branch absorbs it — no change todecideThreading, no heuristic on message content, and the customer-autoresponder case (which legitimately carries our token inReferencestoo) is untouched because the correlation isproviderMessageId, not the token.Design decisions
selfEchoGuardis optional and wired unconditionally only in the composition root (src/composition/root.ts), since every deployment that root builds is Gmail-backed. Absent everywhere else (API deps, delivery-worker deps) by default — a deployment with no self-reflecting transport behaves exactly as before this guard existed. Flagging for sign-off: confirm this is the right default posture if/when a non-Gmail transport is added — the guard would need explicit wiring there too, and nothing currently forces that.claim()for the same provider id wins that race first (an unusually fast push-triggered reconcile), the pre-seed is a no-op (never overwrites an existing row) and the message ingests normally — reproducing the pre-HT-49 failure mode for that one send, not a new one. Documented inspecs/mail/inbound-ingestion.md§5. Flagging in case the maintainer wants a stronger guarantee here rather than accepting the race.providerMessageId, not an extension of theMessage-ID/Referencesscan — chosen specifically so it can't misfire on a customer's legitimate autoresponder reply, which carries our token in the sameReferencesposition.Review — 5 adversarial findings (3 actionable), fixes applied
Referencestoken, once added, would cause Gmail's self-echo of the agent's own sent reply to be re-appended as a phantom inbound message, reopening closed conversations. Fixed viapreSuppressOwnSend+SelfEchoGuardDeps(commit 2, full mechanism above).delivery-worker.ts's retry path (attemptDeliveryOfClaimedThread) shares the exact same self-echo exposure assendReply's direct path but wasn't wired to the guard. Fixed:DeliveryWorkerDeps.selfEchoGuardthreaded throughrunDeliveryWorker→attemptDeliveryOfClaimedThread.send.test.ts(see Verification) — auto-fixed, not a logic change.isOwnMessageReflection'sMessage-IDcorrelation to also scanReferencesfor self-echo detection — rejected because it would also misfire on a customer's legitimate autoresponder reply, which carries our token in the same position for a legitimate reason.Verification
Independent gate exit codes — ran for real, in this worktree, on this code:
npm run typecheck— exit 0, clean, no errors.npm run lint(biome check .) — clean after one auto-fix pass (npx biome check --writeonsend.test.ts, formatting only); re-ran lint after, exit 0, clean.src/mail/send.test.ts,src/api/index.ts-adjacent,src/mail/ingest.test.ts,src/store/inbound-deliveries.test.ts) — exit 0, all passing, including the new self-echo suppression fixture and thepreSuppressOwnSendrace-concession case.git status— clean tree, nothing uncommitted.Link: https://resonantiq.atlassian.net/browse/HT-49
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Message-IDby ensuring the reply’s minted message id is always the finalReferencesentry.Documentation
Referencesand self-echo behavior.Tests