fix(outbound): guard reply/forward against a not-yet-submitted parent#521
Merged
Conversation
Threading is composed once, at accept time, off the parent's RFC 5322
Message-ID (identity.Message.ThreadMessageID). For an outbound parent that
id is provider_message_id, which the send worker only records once it
submits to SES (MarkOutboundSentTx).
Reply or forward to such a parent inside that window and ThreadMessageID()
returns "", so In-Reply-To/References are omitted from the composed bytes
PERMANENTLY and the recipient's client forks a new thread. The window is
normally sub-second but widens to the retry horizon during a provider
outage. GetRepliableMessage did not catch this because it gates on the
review/hold `status` column, which reads 'sent' at accept time, while
delivery truth lives in delivery_status/provider_message_id.
Fail closed with a retriable 409 message_not_yet_delivered instead of
silently forking. Guarded at loadRepliableMessage, the shared parent
resolution seam, so reply and forward are both covered.
The delivery_status IN ('accepted','sending') clause is load-bearing: a
terminally failed send is also provider_message_id='' and would never gain
one, so 409-ing it would strand the caller in an unclearable retry loop.
message_not_yet_delivered is a compatible addition — the error `code`
vocabulary is a documented open set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 tasks
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.
Problem
Reported externally: replies to a message an e2a agent itself sent don't thread in Gmail (or any RFC-compliant client) — they show up as a separate row instead of stacking under the original.
The reporter's root cause (bare SES id stored in
In-Reply-Towithout the@<region>.amazonses.comsuffix) is already fixed on main byc618738d(qualifyMessageIDDomain). That fix lives insidesendOnce, the shared chokepoint for both the sync and async send paths, so it already holds for async.This PR closes the remaining async-only hole in the same story.
The gap
Threading is composed once, at accept time, off the parent's RFC 5322 Message-ID (
identity.Message.ThreadMessageID). For an outbound parent that id isprovider_message_id— which the send worker only records when it submits to SES (MarkOutboundSentTx).Reply/forward to such a parent inside that window and
ThreadMessageID()returns"", soIn-Reply-To/Referencesare omitted from the composed bytes permanently — the recipient's client forks a new thread. No later fixup is possible; the bytes are already persisted.GetRepliableMessagedidn't catch this because it gates on the review/holdstatuscolumn, which reads'sent'at accept time (meaning "not held"), while delivery truth lives indelivery_status/provider_message_id.Window is normally sub-second, but widens to the retry horizon during a provider outage, when messages sit in
accepted.The fix
Fail closed with a retriable 409
message_not_yet_deliveredinstead of silently forking. Guarded atloadRepliableMessage— the shared parent-resolution seam — so reply and forward are both covered by one call site (each verified independently by the test).Every clause is load-bearing:
direction == outbound— inbound parents anchor onemail_message_id, written at intake, never empty-then-filled.provider_message_id == ""— the exact condition makingThreadMessageID()return"".delivery_status IN ('accepted','sending')— a terminally failed send is alsoprovider_message_id=''and will never gain one; 409-ing it would strand the caller in an unclearable retry loop.method != 'loopback'— states the invariant explicitly (loopback self-sends carry a synthetic provider id and are already excluded by the clauses above).GetRepliableMessagegainsmethod+delivery_statusin its SELECT to support this.Contract impact
message_not_yet_deliveredis a compatible addition: the errorcodevocabulary is a documented open set ("tolerate unknown values… new codes may be added over time"), so an older client falls back to the 409 status. No fields removed, no signatures changed — the SDK diff is doc-comments only.Spec + SDK bases regenerated (
make spec,make generate-sdk).Tests
New
internal/e2e/reply_unsubmitted_parent_e2e_test.goreproduces the window deterministically, with no sleeps: a newtestutil.WithManualJobs()builds the River client but never starts it, so the parent provably cannot be submitted. Phase 1 asserts 409 on both reply and forward;ts.StartJobs()then releases the queue and phase 2 asserts reply → 200 withIn-Reply-To/Referencesanchored on the qualifiedprovider_message_id.Verified the test actually catches the bug: with the guard stubbed out, the reply silently returned
202 {"status":"accepted"}— the exact silent fork.Also green:
go build ./...,./internal/httpapi/...(incl. all error-code catalog gates),make spec-check,make generate-sdk-check.🤖 Generated with Claude Code