diff --git a/api/openapi.yaml b/api/openapi.yaml index 49ca7a94..a4e13068 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3621,6 +3621,9 @@ components: html: maxLength: 1048576 type: string + quote_history: + 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." + type: boolean reply_all: type: boolean reply_to: diff --git a/cli/src/__tests__/args.test.ts b/cli/src/__tests__/args.test.ts index 3f009674..80f1d03d 100644 --- a/cli/src/__tests__/args.test.ts +++ b/cli/src/__tests__/args.test.ts @@ -240,6 +240,12 @@ describe("scheduled send help", () => { }); }); +describe("quoted-history reply help", () => { + it("labels --quote-history as experimental", () => { + expect(USAGE).toMatch(/--quote-history[\s\S]*experimental[\s\S]*may change or be removed/i); + }); +}); + describe("getConversationId (FIX 3: --conversation-id / --conversation precedence)", () => { let mockStderr: ReturnType; let mockExit: ReturnType; diff --git a/cli/src/__tests__/send.test.ts b/cli/src/__tests__/send.test.ts index 68172a7c..215f989f 100644 --- a/cli/src/__tests__/send.test.ts +++ b/cli/src/__tests__/send.test.ts @@ -217,6 +217,29 @@ describe("send/reply commands", () => { expect(process.exitCode).toBe(0); }); + it("passes --quote-history through to the SDK reply call", async () => { + mockReply.mockResolvedValue({ messageId: "msg_q", status: "sent" }); + const { reply } = await import("../commands/send.js"); + + await reply("msg_orig", { body: "answer", quoteHistory: true }); + + expect(mockReply.mock.calls[0][2].quoteHistory).toBe(true); + // 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 () => { + mockReply.mockResolvedValue({ messageId: "msg_nq", status: "sent" }); + const { reply } = await import("../commands/send.js"); + + await reply("msg_orig", { body: "answer", quoteHistory: false }); + + // Absent-or-false stays off the wire so the request matches the + // documented server default exactly. + expect(mockReply.mock.calls[0][2].quoteHistory).toBeUndefined(); + }); + it("sends markup-only HTML whose derived text fallback is empty", async () => { mockReadFileSync.mockReturnValue('
'); mockSend.mockResolvedValue({ messageId: "msg_img", status: "sent" }); diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index 1f8aa3dd..1548c18e 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -111,6 +111,9 @@ Usage: --agent Sending inbox (or config agent_email / E2A_AGENT_EMAIL) --json Print the full send result as JSON e2a reply [options] Reply in-thread (same body options as send) + --quote-history Experimental (may change or be removed): the server appends the + original message beneath the reply body, mail-client style + ("On , wrote:" + '>'-quoted text / blockquote HTML) e2a messages list [options] List messages, oldest first --direction inbound|outbound|all --since Messages created AT or after this timestamp @@ -617,7 +620,7 @@ async function main() { break; case "reply": checkFlags(args, [ - "--body", "--body-file", "--html-file", "--attach", "--reply-to", "--send-at", "--agent", "--idempotency-key", "--json", + "--body", "--body-file", "--html-file", "--attach", "--reply-to", "--send-at", "--quote-history", "--agent", "--idempotency-key", "--json", ]); await reply(getPositionals(args, 1, "usage: e2a reply [options]")[0], { attach: getFlagsChecked(args, "--attach"), @@ -626,6 +629,7 @@ async function main() { htmlFile: getFlagChecked(args, "--html-file"), replyTo: getFlagChecked(args, "--reply-to"), sendAt: getFlagChecked(args, "--send-at"), + quoteHistory: hasFlag(args, "--quote-history"), agent: getFlagChecked(args, "--agent"), idempotencyKey: getFlagChecked(args, "--idempotency-key"), json: hasFlag(args, "--json"), diff --git a/cli/src/commands/send.ts b/cli/src/commands/send.ts index 0998e32a..10cb2569 100644 --- a/cli/src/commands/send.ts +++ b/cli/src/commands/send.ts @@ -31,6 +31,7 @@ export interface ReplyOptions { idempotencyKey?: string; attach?: string[]; sendAt?: string; + quoteHistory?: boolean; } const MIME_BY_EXT: Record = { @@ -67,7 +68,7 @@ function readAttachments(paths: string[] | undefined): Attachment[] | undefined const SEND_USAGE = "usage: e2a send --to --subject (--body | --body-file | --html-file ) [--conversation-id ] [--reply-to ] [--send-at ] [--agent ] [--json]"; const REPLY_USAGE = - "usage: e2a reply (--body | --body-file | --html-file ) [--reply-to ] [--send-at ] [--agent ] [--json]"; + "usage: e2a reply (--body | --body-file | --html-file ) [--reply-to ] [--send-at ] [--quote-history] [--agent ] [--json]"; /** * Parse the optional --send-at flag into a Date for scheduled send. Requires an @@ -201,7 +202,16 @@ export async function reply(messageId: string | undefined, opts: ReplyOptions): const result = await client.messages.reply( agentEmail, messageId, - { text: body, html: htmlBody, replyTo: opts.replyTo, attachments: readAttachments(opts.attach), sendAt }, + { + text: body, + html: htmlBody, + replyTo: opts.replyTo, + attachments: readAttachments(opts.attach), + sendAt, + // Experimental server-side quoted history. Absent-or-false stays off the + // wire so the request matches the documented default exactly. + quoteHistory: opts.quoteHistory || undefined, + }, opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : undefined, ); emitSendResult(result, opts.json); diff --git a/docs/api.md b/docs/api.md index 04372818..63980dd4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -457,6 +457,17 @@ declared stable. message reaches a terminal-or-held state or a bounded timeout; a future `send_at` instead returns `status=scheduled` immediately and does not wait until that time. +- `quote_history` **(experimental)** on reply: when `true`, the server appends + the referenced message beneath the reply body as mail-client-style quoted + history — an `On , wrote:` attribution line, the original + 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). 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/httpapi/outbound.go b/internal/httpapi/outbound.go index 77934dc1..ba5d07bd 100644 --- a/internal/httpapi/outbound.go +++ b/internal/httpapi/outbound.go @@ -432,6 +432,7 @@ type ReplyRequest struct { ReplyTo string `json:"reply_to,omitempty" maxLength:"320" doc:"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."` Attachments []outbound.Attachment `json:"attachments,omitempty" nullable:"false" doc:"File attachments (base64 in each item's data). Limits: at most 10 attachments, each ≤ 10 MiB decoded, and ≤ 25 MiB decoded combined. Exceeding the count → 400 invalid_request; exceeding a size → 413 payload_too_large."` Unsubscribe UnsubscribeOptions `json:"unsubscribe,omitempty" doc:"Beta: opts this message into e2a-managed unsubscribe handling. This field may change before it is declared stable."` + QuoteHistory bool `json:"quote_history,omitempty" doc:"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."` SendAt *time.Time `json:"send_at,omitempty" format:"date-time" doc:"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."` } @@ -577,6 +578,16 @@ func (s *Server) handleReply(ctx context.Context, in *replyInput) (*sendOutput, ConversationID: b.ConversationID, ReplyTo: b.ReplyTo, Attachments: b.Attachments, Unsubscribe: outboundUnsubscribe(b.Unsubscribe), } + // EXPERIMENTAL quote_history: rewrite the caller's body parts with the + // parent quoted beneath, BEFORE deliver — so review holds, idempotency + // replay, and the stored outbound row all see the final composed content. + if b.QuoteHistory { + qctx := outbound.ExtractForwardContext(msg.RawMessage) + req.Body = outbound.BuildReplyQuoteBody(req.Body, qctx) + if req.HTMLBody != "" { + req.HTMLBody = outbound.BuildReplyQuoteHTMLBody(req.HTMLBody, qctx) + } + } req.CC = agent.StripAgentSelfAliases(req.CC, ag.EmailAddress()) req.BCC = agent.StripAgentSelfAliases(req.BCC, ag.EmailAddress()) // Re-count the FINAL, post-expansion recipient set. reply_all fans the diff --git a/internal/httpapi/outbound_reply_quote_test.go b/internal/httpapi/outbound_reply_quote_test.go new file mode 100644 index 00000000..4f5e8bc6 --- /dev/null +++ b/internal/httpapi/outbound_reply_quote_test.go @@ -0,0 +1,52 @@ +package httpapi + +import ( + "strings" + "testing" +) + +// The quote_history flag rewrites the delivered body at accept time; these +// tests pin the composed shape at the handler boundary via the captured +// SendRequest (fixture msg_in1: From alice@x.com, no Date header, body "hi"). + +func TestReplyQuoteHistoryComposesBody(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", "quote_history": true}) + if code != 200 { + t.Fatalf("want 200, got %d", code) + } + got := lastDeliveredReq().Body + want := "thanks\r\n\r\nalice@x.com wrote:\r\n> hi\r\n" + if got != want { + t.Fatalf("delivered Body = %q, want %q", got, want) + } +} + +func TestReplyQuoteHistoryHTMLFallsBackToEscapedText(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", "html": "

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/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.go b/internal/outbound/quote.go new file mode 100644 index 00000000..7be84ad7 --- /dev/null +++ b/internal/outbound/quote.go @@ -0,0 +1,86 @@ +package outbound + +import ( + "strings" +) + +// EXPERIMENTAL: reply quote-history composition. Generalizes the forward +// quote block (forward.go) to the mail-client reply shape: the caller's +// reply body on top, an "On , wrote:" attribution line, then +// the parent body as a quote (">"-prefixed text / blockquote HTML). Reuses +// ForwardContext for header + body extraction so both composition paths +// stay lexically consistent. + +// BuildReplyQuoteAttribution renders the attribution line above the quoted +// parent. Degrades gracefully when the parent's headers failed to parse. +func BuildReplyQuoteAttribution(ctx ForwardContext) string { + from := ctx.From + if from == "" { + from = "the sender" + } + if ctx.Date != "" { + return "On " + ctx.Date + ", " + from + " wrote:" + } + return from + " wrote:" +} + +// BuildReplyQuoteBody composes the text/plain body of a quoted reply: the +// caller's reply text, a blank line, the attribution line, then the parent +// text with every line ">"-prefixed. A parent that already carries ">" +// quoting nests naturally (">>"), matching mail-client behavior. An empty +// parent text drops the quote block entirely — the reply goes out exactly +// as the caller wrote it. +func BuildReplyQuoteBody(replyText string, ctx ForwardContext) string { + if ctx.Text == "" { + return replyText + } + var buf strings.Builder + buf.WriteString(strings.TrimRight(replyText, "\r\n")) + buf.WriteString("\r\n\r\n") + buf.WriteString(BuildReplyQuoteAttribution(ctx)) + buf.WriteString("\r\n") + text := strings.ReplaceAll(ctx.Text, "\r\n", "\n") + text = strings.TrimRight(text, "\n") + for _, line := range strings.Split(text, "\n") { + if strings.HasPrefix(line, ">") { + // Existing quote level: extend without inserting a space so + // depth markers stay compact (">>"), the way clients emit them. + buf.WriteString(">") + } else { + buf.WriteString("> ") + } + buf.WriteString(line) + buf.WriteString("\r\n") + } + return buf.String() +} + +// BuildReplyQuoteHTMLBody composes the text/html body of a quoted reply. +// The caller's HTML is emitted as-is (caller-controlled markup, same +// contract as forward); the parent HTML is wrapped in a Gmail-style +// blockquote under the attribution line. Falls back to the escaped parent +// text in
 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(`
`) + buf.WriteString("\r\n") + buf.WriteString(htmlEscape(BuildReplyQuoteAttribution(ctx))) + buf.WriteString("
\r\n") + buf.WriteString(`
`) + buf.WriteString("\r\n") + if ctx.HTML != "" { + buf.WriteString(ctx.HTML) + } else { + buf.WriteString("
")
+		buf.WriteString(htmlEscape(ctx.Text))
+		buf.WriteString("
") + } + buf.WriteString("\r\n
\r\n
") + return buf.String() +} diff --git a/internal/outbound/quote_test.go b/internal/outbound/quote_test.go new file mode 100644 index 00000000..391386ee --- /dev/null +++ b/internal/outbound/quote_test.go @@ -0,0 +1,97 @@ +package outbound + +import ( + "fmt" + "strings" + "testing" +) + +func TestBuildReplyQuoteBodyBasic(t *testing.T) { + ctx := ForwardContext{ + From: "gretta@example.com", + Date: "Thu, 31 Jul 2026 03:30:47 +0000", + Text: "line one\r\nline two", + } + got := BuildReplyQuoteBody("Thanks, agreed.", ctx) + want := "Thanks, agreed.\r\n\r\nOn Thu, 31 Jul 2026 03:30:47 +0000, gretta@example.com wrote:\r\n> line one\r\n> line two\r\n" + if got != want { + t.Fatalf("got:\n%q\nwant:\n%q", got, want) + } +} + +func TestBuildReplyQuoteBodyNestsExistingQuotes(t *testing.T) { + ctx := ForwardContext{ + From: "a@example.com", + Text: "new text\n> older text\n>> oldest", + } + got := BuildReplyQuoteBody("Top.", ctx) + for _, want := range []string{"> new text\r\n", ">> older text\r\n", ">>> oldest\r\n"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in:\n%q", want, got) + } + } +} + +func TestBuildReplyQuoteBodyEmptyParentIsVerbatim(t *testing.T) { + if got := BuildReplyQuoteBody("Body only.", ForwardContext{From: "x@example.com"}); got != "Body only." { + t.Fatalf("expected verbatim body, got %q", got) + } +} + +func TestBuildReplyQuoteHTMLBodyPrefersParentHTML(t *testing.T) { + ctx := ForwardContext{ + From: "a@example.com", + Date: "Thu, 31 Jul 2026 03:30:47 +0000", + Text: "plain", + HTML: "

rich & original

", + } + got := BuildReplyQuoteHTMLBody("

reply

", ctx) + if !strings.Contains(got, "rich & original

") { + t.Fatalf("expected blockquote with parent HTML, got:\n%q", got) + } + if !strings.Contains(got, "On Thu, 31 Jul 2026 03:30:47 +0000, a@example.com wrote:") { + t.Fatalf("missing attribution, got:\n%q", got) + } +} + +func TestBuildReplyQuoteHTMLBodyFallsBackToEscapedText(t *testing.T) { + ctx := ForwardContext{From: "a@example.com", Text: "1 < 2 & 3"} + got := BuildReplyQuoteHTMLBody("

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) + } +} + +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/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..2866f9e7 100644 --- a/tests/contract/scenarios.yaml +++ b/tests/contract/scenarios.yaml @@ -1564,3 +1564,112 @@ 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. 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 + - 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 + send_at: "{future_rfc3339}" + expect: + status: 202 + body_match: + status: scheduled + 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 + send_at: "{future_rfc3339}" + expect: + status: 202 + body_match: + status: scheduled + 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