From 88cb2b239e1cb4c5379f12e4a419454f11a555a7 Mon Sep 17 00:00:00 2001
From: jiashuoz rich & original reply when the parent has no HTML part; drops the quote block
+// when the parent has neither.
+func BuildReplyQuoteHTMLBody(replyHTML string, ctx ForwardContext) string {
+ if ctx.HTML == "" && ctx.Text == "" {
+ return replyHTML
+ }
+ var buf strings.Builder
+ buf.WriteString(strings.TrimSpace(replyHTML))
+ buf.WriteString("\r\n
\r\n")
+ buf.WriteString(`
\r\n")
+ buf.WriteString(``)
+ buf.WriteString("\r\n")
+ if ctx.HTML != "" {
+ buf.WriteString(ctx.HTML)
+ } else {
+ buf.WriteString("
\r\n")
+ buf.WriteString(htmlEscape(ctx.Text))
+ buf.WriteString("")
+ }
+ buf.WriteString("\r\nrich & original
reply
", ctx) + if !strings.Contains(got, "1 < 2 & 3") { + t.Fatalf("expected escaped text fallback, got:\n%q", got) + } +} + +func TestBuildReplyQuoteAttributionDegrades(t *testing.T) { + if got := BuildReplyQuoteAttribution(ForwardContext{}); got != "the sender wrote:" { + t.Fatalf("got %q", got) + } + if got := BuildReplyQuoteAttribution(ForwardContext{From: "b@example.com"}); got != "b@example.com wrote:" { + t.Fatalf("got %q", got) + } +} From e22e463d8d9789d254a56851a909d256a4c6d559 Mon Sep 17 00:00:00 2001 From: Jace
thanks
", "quote_history": true}) + if code != 200 { + t.Fatalf("want 200, got %d", code) + } + html := lastDeliveredReq().HTMLBody + if !strings.Contains(html, "thanks
") || !strings.Contains(html, "hi") { + t.Fatalf("delivered HTMLBody missing quoted block, got %q", html) + } +} + +func TestReplyWithoutQuoteHistoryIsVerbatim(t *testing.T) { + srv := testServer(t) + code, _ := postJSON(t, srv.URL+"/v1/agents/support%40acme.com/messages/msg_in1/reply", "good", + map[string]any{"text": "thanks"}) + if code != 200 { + t.Fatalf("want 200, got %d", code) + } + if got := lastDeliveredReq().Body; got != "thanks" { + t.Fatalf("delivered Body = %q, want verbatim %q", got, "thanks") + } + if got := lastDeliveredReq().HTMLBody; got != "" { + t.Fatalf("delivered HTMLBody = %q, want empty", got) + } +} diff --git a/mcp/src/tools/messages.ts b/mcp/src/tools/messages.ts index df7f2ea7..670a152a 100644 --- a/mcp/src/tools/messages.ts +++ b/mcp/src/tools/messages.ts @@ -237,6 +237,12 @@ export function registerMessageTools(server: McpServer, client: McpClient): void "Stable key for retry-safe replies. A natural choice is the inbound `message_id` you're replying to — the same triggering event yields the same key, so a retry replays the original response instead of double-sending. Omit to let the SDK mint a fresh UUIDv4 per call.", ), send_at: sendAtField, + quote_history: z + .boolean() + .optional() + .describe( + "Experimental (may change or be removed before stable): if true, the server appends the message being replied to as mail-client-style quoted history beneath the reply body — an 'On, wrote:' attribution line followed by the original text ('>'-prefixed) and, when `html` is supplied, the original HTML in a blockquote. Composition happens server-side at accept time, so a held reply shows the reviewer the final quoted content. Only the body parts you supply are quoted (a text-only reply stays text-only). Defaults to false (the body is sent exactly as provided).", + ), email: emailSelector, }), }, @@ -262,6 +268,7 @@ export function registerMessageTools(server: McpServer, client: McpClient): void : {}), ...(args.reply_to !== undefined ? { replyTo: args.reply_to } : {}), ...(args.send_at !== undefined ? { sendAt: new Date(args.send_at) } : {}), + ...(args.quote_history !== undefined ? { quoteHistory: args.quote_history } : {}), }, opts, args.email, diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index ec3e1cc8..26d539c3 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -420,6 +420,18 @@ describe("e2a MCP server", () => { } }); + it("labels reply_to_message's quote_history as experimental with the server default", async () => { + const { tools } = await client.listTools(); + const byName = new Map(tools.map((t) => [t.name, t])); + const properties = (byName.get("reply_to_message")?.inputSchema as { + properties?: Record ; + })?.properties ?? {}; + const description = properties.quote_history?.description ?? ""; + expect(description).toMatch(/experimental.*may change or be removed/i); + expect(description).toMatch(/quoted history/i); + expect(description).toMatch(/defaults to false/i); + }); + it("documents how conversation_id binds email to an agent runtime thread", async () => { const { tools } = await client.listTools(); const byName = new Map(tools.map((tool) => [tool.name, tool])); @@ -1002,6 +1014,31 @@ describe("e2a MCP server", () => { ); }); + it("reply_to_message maps the experimental quote_history flag to quoteHistory", async () => { + await client.callTool({ + name: "reply_to_message", + arguments: { message_id: "msg_in", text: "thanks", quote_history: true }, + }); + expect(stub.reply).toHaveBeenCalledWith( + "msg_in", + expect.objectContaining({ quoteHistory: true }), + {}, + undefined, + ); + + // Omitted stays off the SDK call so the wire matches the server default. + await client.callTool({ + name: "reply_to_message", + arguments: { message_id: "msg_in", text: "thanks" }, + }); + expect(stub.reply).toHaveBeenLastCalledWith( + "msg_in", + { text: "thanks" }, + {}, + undefined, + ); + }); + it("update_message_labels forwards args to client.updateMessageLabels", async () => { await client.callTool({ name: "update_message_labels", diff --git a/sdks/python/src/e2a/v1/client.py b/sdks/python/src/e2a/v1/client.py index 504947fe..959d5266 100644 --- a/sdks/python/src/e2a/v1/client.py +++ b/sdks/python/src/e2a/v1/client.py @@ -628,6 +628,7 @@ async def reply( body: Body, *, unsubscribe: Optional[UnsubscribeInput] = None, + quote_history: Optional[bool] = None, wait: Optional[Literal["sent"]] = None, idempotency_key: Optional[str] = None, ) -> SendResultView: @@ -637,12 +638,23 @@ async def reply( it is declared stable. The optional managed-unsubscribe field is also beta, and ``wait="sent"`` requests the same bounded wait (see :meth:`send`). + + Experimental: pass ``quote_history=True`` to have the server append + the referenced message as mail-client-style quoted history beneath + the reply body (an attribution line plus the '>'-quoted text, and a + blockquote when an HTML body is supplied); when given, it wins over + any ``quote_history`` already present in ``body``. This option may + change or be removed before it is declared stable. """ req = _coerce(ReplyRequest, body) if unsubscribe is not None: if req is body: req = req.model_copy() req.unsubscribe = _coerce(UnsubscribeOptions, unsubscribe) + if quote_history is not None: + if req is body: + req = req.model_copy() + req.quote_history = quote_history return await self._c._write_keyed( lambda h: self._api.reply_to_message(email, message_id, req, wait=wait, _headers=h), idempotency_key, diff --git a/sdks/python/src/e2a/v1/generated/models/reply_request.py b/sdks/python/src/e2a/v1/generated/models/reply_request.py index 3898c5f0..70255fd5 100644 --- a/sdks/python/src/e2a/v1/generated/models/reply_request.py +++ b/sdks/python/src/e2a/v1/generated/models/reply_request.py @@ -35,13 +35,14 @@ class ReplyRequest(BaseModel): cc: Optional[List[Annotated[str, Field(strict=True, max_length=320)]]] = Field(default=None, description="Additional Cc recipients. The final message is limited to 50 recipients across to, cc, and bcc combined. Each recipient string (display name + address combined) is limited to 320 characters.") conversation_id: Optional[Annotated[str, Field(strict=True, max_length=200)]] = Field(default=None, description="Caller-assigned application conversation/grouping id override. This value is independent of email thread topology, which is derived from the referenced message. At most 200 characters — deliberately the same cap as the webhook conversation_ids filter-value limit and the message-list conversation_id filter limit (both 200), so an accepted conversation_id is never too long to filter by. Must not contain CR or LF.") html: Optional[Annotated[str, Field(strict=True, max_length=1048576)]] = None + quote_history: Optional[StrictBool] = Field(default=None, description="Experimental: when true, the server appends the referenced message as mail-client-style quoted history beneath the reply body — an 'On , wrote:' attribution line followed by the original text ('>'-prefixed) and, when an html body is supplied, the original HTML in a blockquote. Composition happens at accept time, so a held reply shows the reviewer the final quoted content. Only the body parts the caller supplies are quoted (a text-only reply stays text-only). Defaults to false (the body is sent exactly as provided). This field may change or be removed before it is declared stable.") reply_all: Optional[StrictBool] = None reply_to: Optional[Annotated[str, Field(strict=True, max_length=320)]] = Field(default=None, description="Sets the Reply-To header — where replies to this message are directed. A single RFC 5322 address, optionally with a display name. At most 320 characters (display name + address combined). Defaults to the sending agent's own address.") send_at: Optional[datetime] = Field(default=None, description="Beta: scheduled sending may change before it is declared stable. Optional scheduled-send time (RFC 3339 with a UTC offset). When set to a future instant the reply is accepted immediately and returns status=scheduled; it is submitted at approximately this time (\"not before\", accurate to the scheduler poll interval). A value at or before now sends immediately. Must be no more than 90 days ahead (over → 400 invalid_request). A future direct loopback whose only recipient is the sending agent's own address returns 400 invalid_request because loopback is immediate. Scheduling does not survive a review hold: if held, send_at is dropped and the reply sends on approval (the hold takes precedence over the loopback check). Moving the message to trash before provider submission starts prevents submission; if submission already has a fresh lease, delete returns 409 send_in_progress. Restoring before send_at re-arms it; restoring at or after send_at returns it live with delivery_status=failed and leaves the send canceled.") text: Annotated[str, Field(strict=True, max_length=1048576)] unsubscribe: Optional[UnsubscribeOptions] = Field(default=None, description="Beta: opts this message into e2a-managed unsubscribe handling. This field may change before it is declared stable.") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["attachments", "bcc", "cc", "conversation_id", "html", "reply_all", "reply_to", "send_at", "text", "unsubscribe"] + __properties: ClassVar[List[str]] = ["attachments", "bcc", "cc", "conversation_id", "html", "quote_history", "reply_all", "reply_to", "send_at", "text", "unsubscribe"] model_config = ConfigDict( populate_by_name=True, @@ -116,6 +117,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "cc": obj.get("cc"), "conversation_id": obj.get("conversation_id"), "html": obj.get("html"), + "quote_history": obj.get("quote_history"), "reply_all": obj.get("reply_all"), "reply_to": obj.get("reply_to"), "send_at": obj.get("send_at"), diff --git a/sdks/python/tests/test_v1_client.py b/sdks/python/tests/test_v1_client.py index 044d823b..0ea5e578 100644 --- a/sdks/python/tests/test_v1_client.py +++ b/sdks/python/tests/test_v1_client.py @@ -47,6 +47,7 @@ TemplateView, WebhookDeliveryView, WebhookView, + ReplyRequest, SendEmailRequest, UnsubscribeOptions, ) @@ -692,6 +693,45 @@ async def test_forward_threads_managed_unsubscribe(httpx_mock): assert json.loads(req.content)["unsubscribe"] == {"mode": "managed"} +@pytest.mark.anyio +async def test_reply_threads_quote_history(httpx_mock): + # Experimental: the quote_history kwarg lands on the generated request + # model and serializes onto the wire. + httpx_mock.add_response(json={"message_id": "msg_q1", "status": "sent"}) + async with _client() as c: + await c.messages.reply( + "bot@test.dev", + "msg_1", + {"text": "yo"}, + quote_history=True, + ) + req = httpx_mock.get_requests()[-1] + assert "/v1/agents/bot%40test.dev/messages/msg_1/reply" in str(req.url) + assert json.loads(req.content)["quote_history"] is True + + +@pytest.mark.anyio +async def test_reply_quote_history_kwarg_does_not_mutate_caller_model(httpx_mock): + # Regression: _coerce returns the caller's own model when body is already a + # ReplyRequest — the kwarg must land on a copy, not on the caller's object, + # or reusing one model across replies leaks quote_history into later + # replies. + httpx_mock.add_response(json={"message_id": "msg_q2", "status": "sent"}) + request = ReplyRequest(text="yo") + async with _client() as c: + await c.messages.reply("bot@test.dev", "msg_1", request, quote_history=True) + assert json.loads(httpx_mock.get_requests()[-1].content)["quote_history"] is True + assert request.quote_history is None + + +@pytest.mark.anyio +async def test_reply_without_quote_history_omits_field(httpx_mock): + httpx_mock.add_response(json={"message_id": "msg_q3", "status": "sent"}) + async with _client() as c: + await c.messages.reply("bot@test.dev", "msg_1", {"text": "yo"}) + assert "quote_history" not in json.loads(httpx_mock.get_requests()[-1].content) + + @pytest.mark.anyio async def test_send_wait_sent_passes_query_param(httpx_mock): # wait="sent" is the bounded-wait opt-in (parity with the TS SDK's diff --git a/sdks/typescript/src/v1/generated/models/ReplyRequest.ts b/sdks/typescript/src/v1/generated/models/ReplyRequest.ts index 1335e275..89611f68 100644 --- a/sdks/typescript/src/v1/generated/models/ReplyRequest.ts +++ b/sdks/typescript/src/v1/generated/models/ReplyRequest.ts @@ -32,6 +32,10 @@ export class ReplyRequest { */ 'conversationId'?: string; 'html'?: string; + /** + * Experimental: when true, the server appends the referenced message as mail-client-style quoted history beneath the reply body — an \'On , wrote:\' attribution line followed by the original text (\'>\'-prefixed) and, when an html body is supplied, the original HTML in a blockquote. Composition happens at accept time, so a held reply shows the reviewer the final quoted content. Only the body parts the caller supplies are quoted (a text-only reply stays text-only). Defaults to false (the body is sent exactly as provided). This field may change or be removed before it is declared stable. + */ + 'quoteHistory'?: boolean; 'replyAll'?: boolean; /** * Sets the Reply-To header — where replies to this message are directed. A single RFC 5322 address, optionally with a display name. At most 320 characters (display name + address combined). Defaults to the sending agent\'s own address. @@ -82,6 +86,12 @@ export class ReplyRequest { "type": "string", "format": "" }, + { + "name": "quoteHistory", + "baseName": "quote_history", + "type": "boolean", + "format": "" + }, { "name": "replyAll", "baseName": "reply_all", diff --git a/sdks/typescript/test/v1/client.test.ts b/sdks/typescript/test/v1/client.test.ts index 494a88b3..5e93306e 100644 --- a/sdks/typescript/test/v1/client.test.ts +++ b/sdks/typescript/test/v1/client.test.ts @@ -381,6 +381,25 @@ describe("E2AClient", () => { expect(result.scheduledAt).toEqual(sendAt); }); + it("messages.reply serializes the experimental quoteHistory flag", async () => { + globalThis.fetch = mockFetch(200, { message_id: "msg_quoted_reply", status: "sent" }); + + await client.messages.reply("bot@test.dev", "msg_1", { + text: "Quoted reply", + quoteHistory: true, + }); + + expect(JSON.parse(lastCall().init.body as string).quote_history).toBe(true); + }); + + it("messages.reply omits quote_history when quoteHistory is not set", async () => { + globalThis.fetch = mockFetch(200, { message_id: "msg_plain_reply", status: "sent" }); + + await client.messages.reply("bot@test.dev", "msg_1", { text: "Plain reply" }); + + expect(JSON.parse(lastCall().init.body as string)).not.toHaveProperty("quote_history"); + }); + it("messages.forward serializes sendAt and parses the scheduled result", async () => { const sendAt = new Date("2026-08-01T16:00:00.000Z"); globalThis.fetch = mockFetch(202, { diff --git a/sdks/typescript/test/v1/client.types.ts b/sdks/typescript/test/v1/client.types.ts index da83c954..493a7671 100644 --- a/sdks/typescript/test/v1/client.types.ts +++ b/sdks/typescript/test/v1/client.types.ts @@ -22,11 +22,16 @@ import { import type { EmailSentData, WebhookEvent } from "../../src/v1/webhook-signature.js"; import type { WSEvent } from "../../src/v1/ws.js"; import { E2AClient } from "../../src/v1/client.js"; +import type { ReplyInput } from "../../src/v1/client.js"; import type { InboundEmail } from "../../src/v1/inbound.js"; const senderFilter: ListMessagesParams = { from_: "alice@example.com" }; void senderFilter; +// Experimental: quoteHistory is an optional boolean on reply input. +const quotedReply: ReplyInput = { text: "ok", quoteHistory: true }; +void quotedReply; + const summaryThreadIDIsOptional: Pick = {}; const detailThreadIDIsOptional: Pick = {}; void summaryThreadIDIsOptional; diff --git a/tests/contract/scenarios.yaml b/tests/contract/scenarios.yaml index ac59a320..52d7c06d 100644 --- a/tests/contract/scenarios.yaml +++ b/tests/contract/scenarios.yaml @@ -1564,3 +1564,106 @@ scenarios: path: /v1/contacts/investor@fund.vc?confirm=DELETE expect: status: 200 + + - name: reply_quote_history + description: > + Experimental quote_history on reply: when true, the server composes the + referenced message beneath the caller's text as mail-client-style quoted + history (an attribution line plus the '>'-prefixed original body) at + accept time, so the stored outbound row already carries the final + content; when the flag is absent the stored body stays byte-verbatim. + The injected parent is Date-less and its raw body is the literal + "body", so the composed attribution is fully deterministic. The detail + view surfaces the quote-stripped parsed.text, so the assertions match + that projection exactly; the raw wire composition is pinned by unit + tests server-side. + setup: + - register_agent: + email: quote-history-{scenario_token}@agents.e2a.dev + - inject_message: + agent_email: "{agent_email}" + from: alice@example.net + subject: quote history target + steps: + - id: quoted_reply + action: request + method: POST + path: /v1/agents/{agent_email}/messages/{injected_message_id}/reply + body: + text: quoted reply conformance + quote_history: true + expect: + status: 202 + body_match: + status: accepted + capture: + quoted_message_id: message_id + + - id: get_quoted_reply + action: request + method: GET + path: /v1/agents/{agent_email}/messages/{quoted_message_id} + expect: + status: 200 + body_match: + id: "{quoted_message_id}" + # The detail view exposes the model-facing parsed.text, which the + # inbound-style parser QUOTE-STRIPS — the ">"-prefixed lines are + # removed and the attribution line remains. Its presence is the + # cross-surface proof the server composed the quote onto the wire + # (raw_message carries the full "> body" block but embeds a Date + # header, so it cannot be matched by equality). The exact wire + # composition is pinned by unit tests in internal/httpapi and + # internal/outbound. + "parsed.text": "quoted reply conformance\n\nalice@example.net wrote:" + + - id: verbatim_reply + action: request + method: POST + path: /v1/agents/{agent_email}/messages/{injected_message_id}/reply + body: + text: verbatim reply conformance + expect: + status: 202 + body_match: + status: accepted + capture: + verbatim_message_id: message_id + + - id: get_verbatim_reply + action: request + method: GET + path: /v1/agents/{agent_email}/messages/{verbatim_message_id} + expect: + status: 200 + body_match: + id: "{verbatim_message_id}" + "parsed.text": verbatim reply conformance + + cleanup: + - id: trash_quoted_reply + action: request + method: DELETE + path: /v1/agents/{agent_email}/messages/{quoted_message_id} + expect: + status: 200 + body_match: + deleted: true + + - id: trash_verbatim_reply + action: request + method: DELETE + path: /v1/agents/{agent_email}/messages/{verbatim_message_id} + expect: + status: 200 + body_match: + deleted: true + + - id: delete_agent_permanently + action: request + method: DELETE + path: /v1/agents/{agent_email}?confirm=DELETE&permanent=true + expect: + status: 200 + body_match: + deleted: true From eaef777aa280d079a07c9078e744f1587e5ec626 Mon Sep 17 00:00:00 2001 From: Jace Date: Sat, 1 Aug 2026 10:33:06 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(api):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?scheduled=20contract=20scenario,=20MIME=20depth=20bound,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli/src/__tests__/send.test.ts | 4 +++- docs/api.md | 7 +++++-- internal/outbound/forward.go | 15 ++++++++++++--- internal/outbound/quote_test.go | 26 ++++++++++++++++++++++++++ tests/contract/scenarios.yaml | 12 +++++++++--- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/cli/src/__tests__/send.test.ts b/cli/src/__tests__/send.test.ts index 6a6adac5..215f989f 100644 --- a/cli/src/__tests__/send.test.ts +++ b/cli/src/__tests__/send.test.ts @@ -224,7 +224,9 @@ describe("send/reply commands", () => { await reply("msg_orig", { body: "answer", quoteHistory: true }); expect(mockReply.mock.calls[0][2].quoteHistory).toBe(true); - expect(process.exitCode).toBe(0); + // A sent reply never sets a failure code; exitCode may be untouched + // (undefined) when this test runs in isolation. + expect(process.exitCode ?? 0).toBe(0); }); it("omits quoteHistory from the reply body when the flag is not set", async () => { diff --git a/docs/api.md b/docs/api.md index 238047c5..63980dd4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -463,8 +463,11 @@ declared stable. text `>`-prefixed, and (when an `html` body is supplied) the original HTML in a blockquote. Composed at accept time, so a held reply shows the reviewer the final quoted content. Only body parts the caller supplies are quoted (a - text-only reply stays text-only). Defaults to `false` (bodies are sent - exactly as provided). May change or be removed before it is declared stable. + text-only reply stays text-only). The quoted parent counts against the + 10 MiB composed-message ceiling, so a small reply to a huge parent can + return `413 payload_too_large` (scope `composed_message`). Defaults to + `false` (bodies are sent exactly as provided). May change or be removed + before it is declared stable. - `send_at` **(beta)** on send/reply/forward must be RFC 3339 with an explicit UTC offset, can be at most 90 days ahead, and does not survive a review hold (approval sends immediately). A future direct loopback whose only recipient diff --git a/internal/outbound/forward.go b/internal/outbound/forward.go index 96dfb8b8..9139931f 100644 --- a/internal/outbound/forward.go +++ b/internal/outbound/forward.go @@ -52,15 +52,24 @@ func ExtractForwardContext(rawMessage []byte) ForwardContext { contentType := msg.Header.Get("Content-Type") encoding := msg.Header.Get("Content-Transfer-Encoding") - ctx.Text, ctx.HTML = extractBodyParts(msg.Body, contentType, encoding) + ctx.Text, ctx.HTML = extractBodyParts(msg.Body, contentType, encoding, 0) return ctx } +// maxExtractMIMEDepth bounds the multipart recursion below. Legitimate mail +// nests two or three levels (mixed > alternative > related); a crafted +// inbound under the 10 MiB cap could otherwise nest tens of thousands of +// parts and pin the request goroutine. Mirrors mailparse.maxMIMEDepth. +const maxExtractMIMEDepth = 32 + // extractBodyParts walks a message body looking for the text/plain and // text/html parts. Recurses into multipart/alternative and // multipart/mixed. The body io.Reader is consumed in a single pass — for // non-multipart bodies the entire reader is treated as a single part. -func extractBodyParts(body io.Reader, contentType, encoding string) (textOut, htmlOut string) { +func extractBodyParts(body io.Reader, contentType, encoding string, depth int) (textOut, htmlOut string) { + if depth > maxExtractMIMEDepth { + return "", "" + } mediaType, params, err := mime.ParseMediaType(contentType) if err != nil { // No Content-Type or malformed — fall through and treat as @@ -99,7 +108,7 @@ func extractBodyParts(body io.Reader, contentType, encoding string) (textOut, ht partType, _, _ := mime.ParseMediaType(partCT) if strings.HasPrefix(partType, "multipart/") { - nestedText, nestedHTML := extractBodyParts(part, partCT, partEnc) + nestedText, nestedHTML := extractBodyParts(part, partCT, partEnc, depth+1) if textOut == "" { textOut = nestedText } diff --git a/internal/outbound/quote_test.go b/internal/outbound/quote_test.go index 0d4e40e2..391386ee 100644 --- a/internal/outbound/quote_test.go +++ b/internal/outbound/quote_test.go @@ -1,6 +1,7 @@ package outbound import ( + "fmt" "strings" "testing" ) @@ -69,3 +70,28 @@ func TestBuildReplyQuoteAttributionDegrades(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestExtractBodyPartsDepthBounded(t *testing.T) { + // A pathologically nested multipart must terminate at the depth bound + // rather than recursing per level. Build 200 genuinely nested layers + // (distinct boundaries), innermost a text part. + nest := func(depth int) string { + body := "Content-Type: text/plain\r\n\r\ndeep\r\n" + for i := depth - 1; i >= 0; i-- { + b := fmt.Sprintf("b%d", i) + body = "Content-Type: multipart/mixed; boundary=" + b + "\r\n\r\n" + + "--" + b + "\r\n" + body + "--" + b + "--\r\n" + } + return body + } + deep := []byte("From: a@x.com\r\n" + nest(200)) + ctx := ExtractForwardContext(deep) + if ctx.Text != "" { + t.Fatalf("expected depth bound to cut extraction, got Text=%q", ctx.Text) + } + // Sanity: the same shape within the bound still extracts. + shallow := []byte("From: a@x.com\r\n" + nest(3)) + if got := ExtractForwardContext(shallow).Text; !strings.Contains(got, "deep") { + t.Fatalf("shallow nesting should extract, got %q", got) + } +} diff --git a/tests/contract/scenarios.yaml b/tests/contract/scenarios.yaml index 52d7c06d..2866f9e7 100644 --- a/tests/contract/scenarios.yaml +++ b/tests/contract/scenarios.yaml @@ -1576,7 +1576,11 @@ scenarios: "body", so the composed attribution is fully deterministic. The detail view surfaces the quote-stripped parsed.text, so the assertions match that projection exactly; the raw wire composition is pinned by unit - tests server-side. + tests server-side. Both replies use a future send_at (composition + happens at accept, before scheduling) so the scenario never submits to + a provider — no real mail in seed mode, and trash-cancel in cleanup + cannot race a submission lease (the same pattern as the scheduled_* + scenarios). setup: - register_agent: email: quote-history-{scenario_token}@agents.e2a.dev @@ -1592,10 +1596,11 @@ scenarios: body: text: quoted reply conformance quote_history: true + send_at: "{future_rfc3339}" expect: status: 202 body_match: - status: accepted + status: scheduled capture: quoted_message_id: message_id @@ -1623,10 +1628,11 @@ scenarios: path: /v1/agents/{agent_email}/messages/{injected_message_id}/reply body: text: verbatim reply conformance + send_at: "{future_rfc3339}" expect: status: 202 body_match: - status: accepted + status: scheduled capture: verbatim_message_id: message_id