Skip to content

feat(api): experimental quote_history flag on reply - #789

Open
jiashuoz wants to merge 3 commits into
mainfrom
experiment/reply-quote-history
Open

feat(api): experimental quote_history flag on reply#789
jiashuoz wants to merge 3 commits into
mainfrom
experiment/reply-quote-history

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Aug 1, 2026

Copy link
Copy Markdown
Member

Summary

Adds an experimental opt-in quote_history boolean to POST /v1/agents/{email}/messages/{id}/reply. When true, the server composes the referenced message beneath the caller's body as mail-client-style quoted history — an On <date>, <sender> wrote: attribution line, the original text >-prefixed, and (when an html body is supplied) the original HTML in a blockquote. Default false: bodies remain byte-verbatim, today's behavior.

Why: API-sent replies currently look terse to human correspondents and give newly added thread participants no context. No peer email API offers this (Graph, Nylas, AgentMail all require hand-rolled quoting — it's a documented pain point for Graph), so it's cheap differentiation for the agent-as-correspondent use case. The composition generalizes the existing forward quote path (internal/outbound/forward.go) into internal/outbound/quote.go; both share ForwardContext extraction.

Design notes:

  • Accept-time composition: review holds, idempotency replay, and the stored outbound row all carry the final quoted content — a HITL reviewer approves exactly what ships (verified live).
  • Symmetric round trip: our own inbound parser (mailparse.stripQuotedReplies) recognizes the emitted On … wrote: marker, so agent-to-agent mail with the flag on adds zero noise to the receiving agent's model-facing parsed.text (verified live against a local instance + Mailpit across a 9-scenario matrix: human recipient, deep-thread nesting >>>, text-only, HTML-only parent, reply-to-own-outbound, HITL hold/approve, idempotent retry, control).
  • Only body parts the caller supplies are quoted (text-only reply stays text-only; HTML-only parent quotes into HTML with a <pre> text fallback). Parent with no extractable body degrades to a verbatim reply.
  • Marked Experimental in the OpenAPI doc (house precedent: send_at, unsubscribe beta markers); additive only, oasdiff-clean.

Client surface checklist

  • Go handler + integration tests (internal/httpapi/outbound_reply_quote_test.go, internal/outbound/quote_test.go)
  • OpenAPI spec + generated types refreshed (make generate is clean)
  • TypeScript SDK — generated base regenerated; ReplyInput flows the generated quoteHistory field per the sendAt precedent (no hand-written mapping layer exists for reply fields), unit + type tests added
  • Python SDK — generated base regenerated and quote_history kwarg on reply (sync mirror is automatic), 3 tests added, mypy clean
  • CLI — --quote-history on e2a reply, wired into checkFlags allowlist + help (marked experimental), tests added
  • MCP tool — quote_history input on reply_to_message (plain optional, no default — no MCP field has one), mapping + schema-description tests added
  • Tests at each surface above (positive + negative/omission case per surface)
  • Contract scenario reply_quote_history in tests/contract/scenarios.yaml — proven through the Go runner; TS/Python runners skip it by established rule (uses inject_message store setup, same as scheduled_reply_fields)
  • Web dashboard checked manually: no reply composer exists in web/, so no dashboard change applies

Migration row skipped — no schema change (composition is request-time; no new columns).

Operational risk

Low. Opt-in flag, default off; no schema change; no new external calls. The composed body passes through the existing outbound size ceilings (413 on overflow, unchanged). Held replies show reviewers the final composed content — behavior reviewers should be told about, not a risk per se. Rollback = revert; no data migration to unwind.

Known caveats (why it's marked experimental):

  • Contract-view assertion note: the message detail view surfaces the quote-stripped parsed.text for outbound rows too, so the contract scenario asserts the attribution-line projection; exact wire bytes are pinned by unit tests.
  • Gmail's quote-collapse rendering verified structurally (Mailpit) but not yet against a live Gmail account — worth one real send before promoting past experimental.

Test plan

  • make test-unit clean; make spec-check + make openapi-compat-check clean (additive, no breaking changes)
  • TS SDK 235/235, CLI 269/269, MCP 293/293, Python 504 passed + mypy clean
  • Go contract runner: TestScenarios/reply_quote_history PASS (Postgres-backed)
  • Live local matrix (server + Mailpit): 9 scenarios incl. HITL hold→approve and agent-to-agent round-trip strip
  • Post-merge: one real send to a Gmail inbox to confirm quote-collapse rendering

🤖 Generated with Claude Code

jiashuoz and others added 3 commits August 1, 2026 10:10
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…und, docs

Contract scenario now uses future send_at so seed mode never submits real
mail and cleanup cannot race a submission lease; extractBodyParts gains
the same depth bound mailparse has (newly hot via reply quoting); the
composed-ceiling interaction is documented; the new CLI test passes in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jiashuoz

jiashuoz commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Adversarial review round completed before handing off (findings + fixes in eaef777):

  • Contract scenario reworked to the scheduled-send pattern — the original version would have been the suite's first immediate provider submission, sending real mail to alice@example.net on every staging seed run and risking suppression-list poisoning; it also raced cleanup DELETE against the send worker's lease (409 send_in_progress flake). Both replies now use a future send_at (composition happens at accept, so the assertions are unchanged) and trash-cancel cleanly.
  • MIME depth bound added to extractBodyParts (mirrors mailparse.maxMIMEDepth) — pre-existing unbounded recursion on the forward path, newly hot via reply quoting; now cut at 32 levels with a nested-200-levels regression test.
  • Docs: noted the quoted parent counts against the 10 MiB composed ceiling (413 payload_too_large, scope composed_message — verified enforced post-composition).
  • CLI test isolation: the new --quote-history test no longer depends on a prior test's exitCode reset.

Cleared during review (verified, no change needed): CRLF/header injection via parent headers (net/mail unfolds; QP encoding canonicalizes), HTML injection (same contract as forward's blockquote), idempotency (dedup hashes raw request bytes, so key reuse with a different flag value correctly 422s), scenario determinism (6× green).

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