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