Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <date>, <sender> 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:
Expand Down
6 changes: 6 additions & 0 deletions cli/src/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.spyOn>;
let mockExit: ReturnType<typeof vi.spyOn>;
Expand Down
23 changes: 23 additions & 0 deletions cli/src/__tests__/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<img src="cid:logo"><table><tr><td></td></tr></table>');
mockSend.mockResolvedValue({ messageId: "msg_img", status: "sent" });
Expand Down
6 changes: 5 additions & 1 deletion cli/src/bin/e2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ Usage:
--agent <email> Sending inbox (or config agent_email / E2A_AGENT_EMAIL)
--json Print the full send result as JSON
e2a reply <message-id> [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 <date>, <sender> wrote:" + '>'-quoted text / blockquote HTML)
e2a messages list [options] List messages, oldest first
--direction <d> inbound|outbound|all
--since <ISO> Messages created AT or after this timestamp
Expand Down Expand Up @@ -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 <message-id> [options]")[0], {
attach: getFlagsChecked(args, "--attach"),
Expand All @@ -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"),
Expand Down
14 changes: 12 additions & 2 deletions cli/src/commands/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface ReplyOptions {
idempotencyKey?: string;
attach?: string[];
sendAt?: string;
quoteHistory?: boolean;
}

const MIME_BY_EXT: Record<string, string> = {
Expand Down Expand Up @@ -67,7 +68,7 @@ function readAttachments(paths: string[] | undefined): Attachment[] | undefined
const SEND_USAGE =
"usage: e2a send --to <email> --subject <s> (--body <text> | --body-file <f> | --html-file <f>) [--conversation-id <id>] [--reply-to <email>] [--send-at <rfc3339>] [--agent <inbox>] [--json]";
const REPLY_USAGE =
"usage: e2a reply <message-id> (--body <text> | --body-file <f> | --html-file <f>) [--reply-to <email>] [--send-at <rfc3339>] [--agent <inbox>] [--json]";
"usage: e2a reply <message-id> (--body <text> | --body-file <f> | --html-file <f>) [--reply-to <email>] [--send-at <rfc3339>] [--quote-history] [--agent <inbox>] [--json]";

/**
* Parse the optional --send-at flag into a Date for scheduled send. Requires an
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <date>, <sender> 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
Expand Down
11 changes: 11 additions & 0 deletions internal/httpapi/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <date>, <sender> 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."`
}

Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions internal/httpapi/outbound_reply_quote_test.go
Original file line number Diff line number Diff line change
@@ -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": "<p>thanks</p>", "quote_history": true})
if code != 200 {
t.Fatalf("want 200, got %d", code)
}
html := lastDeliveredReq().HTMLBody
if !strings.Contains(html, "<p>thanks</p>") || !strings.Contains(html, "<blockquote") || !strings.Contains(html, "<pre>hi</pre>") {
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)
}
}
15 changes: 12 additions & 3 deletions internal/outbound/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
86 changes: 86 additions & 0 deletions internal/outbound/quote.go
Original file line number Diff line number Diff line change
@@ -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 <date>, <from> 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 <pre> 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<br>\r\n")
buf.WriteString(`<div class="e2a_quote">`)
buf.WriteString("\r\n")
buf.WriteString(htmlEscape(BuildReplyQuoteAttribution(ctx)))
buf.WriteString("<br>\r\n")
buf.WriteString(`<blockquote style="margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex">`)
buf.WriteString("\r\n")
if ctx.HTML != "" {
buf.WriteString(ctx.HTML)
} else {
buf.WriteString("<pre>")
buf.WriteString(htmlEscape(ctx.Text))
buf.WriteString("</pre>")
}
buf.WriteString("\r\n</blockquote>\r\n</div>")
return buf.String()
}
Loading