Skip to content

fix(outbound): guard reply/forward against a not-yet-submitted parent#521

Merged
jiashuoz merged 2 commits into
mainfrom
fix/reply-threading-unsubmitted-parent
Jul 18, 2026
Merged

fix(outbound): guard reply/forward against a not-yet-submitted parent#521
jiashuoz merged 2 commits into
mainfrom
fix/reply-threading-unsubmitted-parent

Conversation

@jiashuoz

Copy link
Copy Markdown
Member

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-To without the @<region>.amazonses.com suffix) is already fixed on main by c618738d (qualifyMessageIDDomain). That fix lives inside sendOnce, 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 is provider_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 "", so In-Reply-To/References are omitted from the composed bytes permanently — the recipient's client forks a new thread. No later fixup is possible; the bytes are already persisted.

GetRepliableMessage didn't catch this because it gates on the review/hold status column, which reads 'sent' at accept time (meaning "not held"), while delivery truth lives in delivery_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_delivered instead of silently forking. Guarded at loadRepliableMessage — the shared parent-resolution seam — so reply and forward are both covered by one call site (each verified independently by the test).

if msg.Direction != "outbound" || msg.Method == "loopback" || msg.ProviderMessageID != "" { return nil }
if msg.DeliveryStatus != "accepted" && msg.DeliveryStatus != "sending" { return nil }
return NewError(http.StatusConflict, "message_not_yet_delivered", "...")

Every clause is load-bearing:

  • direction == outbound — inbound parents anchor on email_message_id, written at intake, never empty-then-filled.
  • provider_message_id == "" — the exact condition making ThreadMessageID() return "".
  • delivery_status IN ('accepted','sending') — a terminally failed send is also provider_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).

GetRepliableMessage gains method + delivery_status in its SELECT to support this.

Contract impact

message_not_yet_delivered is a compatible addition: the error code vocabulary 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.go reproduces the window deterministically, with no sleeps: a new testutil.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 with In-Reply-To/References anchored on the qualified provider_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.

--- PASS: TestReplyThreadingSESMessageIDE2E
--- PASS: TestReplyToUnsubmittedOutboundParentE2E

Also green: go build ./..., ./internal/httpapi/... (incl. all error-code catalog gates), make spec-check, make generate-sdk-check.

🤖 Generated with Claude Code

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>
@jiashuoz
jiashuoz merged commit 2b8e193 into main Jul 18, 2026
22 checks passed
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