From 95f48048a8069a20685464f33c6114c5898d8dbb Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Wed, 27 May 2026 19:08:51 -0700 Subject: [PATCH 1/2] feat(api): add /messages/{id}/forward endpoint + Forward across SDKs/CLI/MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing reply path: handler validates ownership, applies HITL/idempotency/rate-limit/domain checks, then composes via new outbound.BuildForward{Subject,Body,HTMLBody} helpers — best-effort MIME extraction of the original body, Gmail-style header block, no In-Reply-To/References (forwards are new threads). HITL fix: buildSendRequestFromMessage now only copies email_message_id into ReplyToMessageID when type="reply", so approved forwards don't accidentally stitch into the original thread on send. Includes: - migration 019 extending messages.message_type CHECK with 'forward' - TS SDK forwardMessage in api/client/inbound-email - CLI: e2a forward --to … [--body …] - MCP: forward_message tool - Compile fix on a pre-existing internal/e2e mismatch where local structs declared To as string instead of []string Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/src/bin/e2a.ts | 13 + cli/src/commands/forward.ts | 44 +++ internal/agent/api.go | 202 ++++++++++++- internal/agent/api_docs.go | 17 +- internal/agent/forward_api_test.go | 280 +++++++++++++++++++ internal/agent/hitl_api.go | 25 +- internal/e2e/e2e_test.go | 18 +- internal/outbound/forward.go | 260 +++++++++++++++++ internal/outbound/forward_test.go | 211 ++++++++++++++ mcp/src/tools/messages.ts | 53 ++++ mcp/tests/http.test.ts | 1 + mcp/tests/tools.test.ts | 18 ++ migrations/019_message_type_forward.sql | 17 ++ sdks/python/src/e2a/v1/generated/__init__.py | 21 +- sdks/typescript/src/v1/api.ts | 14 + sdks/typescript/src/v1/client.ts | 41 +++ sdks/typescript/src/v1/generated/types.ts | 160 ++++++++++- sdks/typescript/src/v1/inbound-email.ts | 25 ++ web/public/openapi.yaml | 116 ++++++++ 19 files changed, 1514 insertions(+), 22 deletions(-) create mode 100644 cli/src/commands/forward.ts create mode 100644 internal/agent/forward_api_test.go create mode 100644 internal/outbound/forward.go create mode 100644 internal/outbound/forward_test.go create mode 100644 migrations/019_message_type_forward.sql diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index a9d7924..310fe23 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -9,6 +9,7 @@ import { } from "../commands/agents.js"; import { inbox } from "../commands/inbox.js"; import { read } from "../commands/read.js"; +import { forward } from "../commands/forward.js"; import { reply } from "../commands/reply.js"; import { send } from "../commands/send.js"; import { config } from "../commands/config.js"; @@ -40,6 +41,7 @@ Usage: e2a inbox [--unread|--read] [--limit N] [--oldest] [--from substr] [--subject substr] [--conversation id] [--since ts] [--until ts] [--token …] List messages (newest first; --oldest for FIFO) e2a read Read a message e2a reply --body … [--reply-all] [--cc …] [--bcc …] + e2a forward --to … [--cc …] [--bcc …] [--body …] e2a send [--to …] [--cc …] [--bcc …] --subject … --body … e2a listen [options] Listen for emails via WebSocket e2a domains list List your domains @@ -226,6 +228,17 @@ async function main() { idempotencyKey: getFlag(args, "--idempotency-key"), }); break; + case "forward": + await forward(args[0], { + to: getFlags(args, "--to"), + cc: getFlags(args, "--cc"), + bcc: getFlags(args, "--bcc"), + body: getFlag(args, "--body"), + htmlBody: getFlag(args, "--html-body"), + from: getFlag(args, "--agent"), + idempotencyKey: getFlag(args, "--idempotency-key"), + }); + break; case "send": await send( getFlags(args, "--to"), diff --git a/cli/src/commands/forward.ts b/cli/src/commands/forward.ts new file mode 100644 index 0000000..01330d0 --- /dev/null +++ b/cli/src/commands/forward.ts @@ -0,0 +1,44 @@ +import { createClient } from "../sdk.js"; + +export async function forward( + messageId: string | undefined, + opts: { + to: string[]; + cc?: string[]; + bcc?: string[]; + body?: string; + htmlBody?: string; + from?: string; + idempotencyKey?: string; + }, +): Promise { + if (!messageId) { + process.stderr.write( + "Usage: e2a forward --to [--cc ] [--bcc ] [--body \"...\"] [--html-body \"...\"]\n", + ); + process.exit(1); + } + if (!opts.to.length) { + process.stderr.write("--to is required (at least one recipient)\n"); + process.exit(1); + } + + const client = createClient({ from: opts.from }); + + if (!client.agentEmail) { + process.stderr.write( + "No agent email configured. Run 'e2a register' first or use --agent.\n", + ); + process.exit(1); + } + + const res = await client.forward(messageId, opts.to, { + cc: opts.cc?.length ? opts.cc : undefined, + bcc: opts.bcc?.length ? opts.bcc : undefined, + body: opts.body, + htmlBody: opts.htmlBody, + idempotencyKey: opts.idempotencyKey, + }); + + process.stdout.write(`Sent: ${res.message_id}\n`); +} diff --git a/internal/agent/api.go b/internal/agent/api.go index de949bf..50a23b3 100644 --- a/internal/agent/api.go +++ b/internal/agent/api.go @@ -284,6 +284,7 @@ func (a *API) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/v1/agents/{email}/messages", a.handleGetMessages).Methods("GET") r.HandleFunc("/api/v1/agents/{email}/messages/{id}", a.handleGetMessage).Methods("GET") r.HandleFunc("/api/v1/agents/{email}/messages/{id}/reply", a.handleReplyToMessage).Methods("POST") + r.HandleFunc("/api/v1/agents/{email}/messages/{id}/forward", a.handleForwardMessage).Methods("POST") r.HandleFunc("/api/v1/domains", a.handleListDomains).Methods("GET") r.HandleFunc("/api/v1/domains", a.handleRegisterDomain).Methods("POST") r.HandleFunc("/api/v1/domains/{domain}/verify", a.handleVerifyDomain).Methods("POST") @@ -1441,7 +1442,7 @@ func (a *API) domainInfoFromRecord(d *identity.Domain) DomainInfo { // handleSendTestEmail when agent.HITLEnabled is true. // // replyToEmailMessageID is the inbound Message-ID being replied to, or "". -// msgType is one of "send", "reply", or "test". +// msgType is one of "send", "reply", "test", or "forward". func (a *API) holdForApproval(w http.ResponseWriter, r *http.Request, agent *identity.AgentIdentity, req outbound.SendRequest, msgType, replyToEmailMessageID string) { var attachmentsJSON []byte if len(req.Attachments) > 0 { @@ -2024,6 +2025,205 @@ func (a *API) handleReplyToMessage(w http.ResponseWriter, r *http.Request) { }) } +// ForwardRequest is the JSON body for /api/v1/agents/{email}/messages/{id}/forward. +type ForwardRequest struct { + To []string `json:"to"` + CC []string `json:"cc,omitempty"` + BCC []string `json:"bcc,omitempty"` + Body string `json:"body,omitempty"` + HTMLBody string `json:"html_body,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + Attachments []outbound.Attachment `json:"attachments,omitempty"` +} + +// handleForwardMessage forwards a previously received email to new recipients. +// @Summary Forward an inbound email +// @Description Forward a previously received email to one or more new recipients. The server prepends the caller's optional comment, then a Gmail-style "Forwarded message" block with the original headers and best-effort extracted body. A forward is treated as a NEW thread — no In-Reply-To/References headers are emitted; pass conversation_id to bind it to an existing thread explicitly. Rate limited to 60 sends per agent per minute; 429 responses carry a `Retry-After` header in delay-seconds form. When the owning agent has HITL enabled, the server returns 202 Accepted with status="pending_approval". +// @Tags Email +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param email path string true "Agent email address" example(my-bot@example.com) +// @Param id path string true "Message ID from the inbound payload" example(msg_abc123) +// @Param request body ForwardMessageRequest true "Forward content" +// @Success 200 {object} SendEmailResponse "Forward sent immediately" +// @Success 202 {object} SendEmailResponse "Forward held for human approval" +// @Failure 400 {string} string "Missing or invalid fields" +// @Failure 401 {string} string "Missing or invalid API key" +// @Failure 403 {string} string "Agent domain not verified" +// @Failure 404 {string} string "Message not found or does not belong to this agent" +// @Failure 409 {string} string "Another request with this Idempotency-Key is in progress" +// @Failure 422 {string} string "Idempotency-Key reused with a different request body" +// @Failure 429 {string} string "Rate limit exceeded" +// @Param Idempotency-Key header string false "Caller-generated unique key (recommend UUIDv4). Retries with the same key + same body replay the original response; with a different body return 422." +// @Router /api/v1/agents/{email}/messages/{id}/forward [post] +func (a *API) handleForwardMessage(w http.ResponseWriter, r *http.Request) { + user, err := a.authenticateUser(r) + if err != nil { + a.writeAuthError(w, r, err) + return + } + + bodyBytes, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBytesSend)) + if err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + replayed, captureW, finalize := a.idempotencyGuard(w, r, user.ID, bodyBytes) + if replayed { + return + } + defer finalize() + w = captureW + + vars := mux.Vars(r) + email := normalizeEmail(vars["email"]) + msgID := vars["id"] + + agent, err := a.resolveAgentForUser(r, email, user) + if err != nil { + http.Error(w, "agent not found", http.StatusNotFound) + return + } + + inbound, err := a.store.GetInboundMessage(r.Context(), msgID) + if err != nil { + http.Error(w, "message not found", http.StatusNotFound) + return + } + if inbound.AgentID != agent.ID { + http.Error(w, "message not found", http.StatusNotFound) + return + } + + if ok, retryAfter := a.sendLimit.AllowWithRetryAfter(agent.ID); !ok { + writeTooManyRequests(w, retryAfter, "rate limit exceeded — max 60 sends per minute per agent") + return + } + + if !agent.DomainVerified { + http.Error(w, "agent domain must be verified before sending", http.StatusForbidden) + return + } + + if a.enforcer != nil { + if err := a.enforcer.CheckMessageSend(r.Context(), user.ID); err != nil { + if limits.WriteLimitError(w, err) { + return + } + log.Printf("[api] limits.CheckMessageSend error: %v", err) + http.Error(w, "limits check failed", http.StatusInternalServerError) + return + } + } + + var req ForwardRequest + if err := json.Unmarshal(bodyBytes, &req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if len(req.To) == 0 && len(req.CC) == 0 { + http.Error(w, "at least one recipient in to or cc is required", http.StatusBadRequest) + return + } + if err := validateRecipients(req.To, req.CC, req.BCC); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := validateConversationID(req.ConversationID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + subject := outbound.BuildForwardSubject(inbound.Subject) + fwdCtx := outbound.ExtractForwardContext(inbound.RawMessage) + composedBody := outbound.BuildForwardBody(req.Body, fwdCtx) + var composedHTML string + if req.HTMLBody != "" || fwdCtx.HTML != "" || fwdCtx.Text != "" { + composedHTML = outbound.BuildForwardHTMLBody(req.HTMLBody, fwdCtx) + } + + sendReq := outbound.SendRequest{ + To: req.To, + CC: req.CC, + BCC: req.BCC, + Subject: subject, + Body: composedBody, + HTMLBody: composedHTML, + ConversationID: req.ConversationID, + Attachments: req.Attachments, + } + + // Pre-clean self-aliases so isSelfSend sees a true self-loop when + // the caller forwarded a message to the agent's own address. + sendReq.CC = stripAgentSelfAliases(sendReq.CC, agent.EmailAddress()) + sendReq.BCC = stripAgentSelfAliases(sendReq.BCC, agent.EmailAddress()) + selfForward := isSelfSend(sendReq, agent.EmailAddress()) + + if agent.HITLEnabled { + // inbound.EmailMessageID is persisted so the review panel can + // attach the InboundContext pane. buildSendRequestFromMessage + // gates ReplyToMessageID on type="reply", so this won't be + // promoted to a threading header on approval. + a.holdForApproval(w, r, agent, sendReq, "forward", inbound.EmailMessageID) + return + } + + if _, err := a.usage.RecordAndCheck(r.Context(), user.ID, agent.ID, agent.Domain, "outbound"); err != nil { + log.Printf("[api] usage recording error: %v", err) + } + + if selfForward { + providerID, err := a.performSelfSend(r.Context(), agent, sendReq) + if err != nil { + log.Printf("[api] self-forward failed: agent=%s error=%v", agent.EmailAddress(), err) + http.Error(w, "self-forward failed", http.StatusInternalServerError) + return + } + markSideEffectCommitted(w) + slug, _, _ := strings.Cut(agent.EmailAddress(), "@") + log.Printf("[mail] dir=outbound type=forward method=loopback from=%s to=%s slug=%s conv_id=%s subject=%q provider_id=%s orig=%s", + agent.EmailAddress(), agent.EmailAddress(), slug, req.ConversationID, subject, providerID, msgID) + w.Header().Set("Content-Type", "application/json") + writeJSON(w, map[string]string{ + "status": "sent", + "message_id": providerID, + "method": "loopback", + }) + return + } + + result, err := a.sender.Send(agent, sendReq) + if err != nil { + if outbound.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("[api] forward failed: agent=%s to=%v error=%v", agent.Domain, req.To, err) + http.Error(w, fmt.Sprintf("delivery failed: %v", err), http.StatusInternalServerError) + return + } + markSideEffectCommitted(w) + + outMsg, err := a.store.CreateOutboundMessage(r.Context(), agent.ID, result.To, result.CC, result.BCC, subject, "forward", result.Method, result.MessageID, req.ConversationID) + if err != nil { + log.Printf("[api] failed to record outbound message: %v", err) + } + + slug, _, _ := strings.Cut(agent.EmailAddress(), "@") + if outMsg != nil { + log.Printf("[mail:%s] dir=outbound type=forward from=%s to=%v slug=%s conv_id=%s subject=%q orig=%s", outMsg.ID, agent.EmailAddress(), result.To, slug, req.ConversationID, subject, msgID) + } + + w.Header().Set("Content-Type", "application/json") + writeJSON(w, map[string]string{ + "status": "sent", + "message_id": result.MessageID, + "method": result.Method, + }) +} + // --- Polling API --- // handleGetMessages lists messages for an agent. diff --git a/internal/agent/api_docs.go b/internal/agent/api_docs.go index d2b96f5..c64e8ed 100644 --- a/internal/agent/api_docs.go +++ b/internal/agent/api_docs.go @@ -64,6 +64,21 @@ type ReplyToMessageRequest struct { Attachments []Attachment `json:"attachments,omitempty"` } // @name ReplyToMessageRequest +// ForwardMessageRequest is the request body for forwarding a message. +// Body and html_body are the caller's optional comment to prepend; the +// server appends a quoted block with the original headers and body. A +// forward is treated as a new thread (no In-Reply-To/References) — pass +// conversation_id to bind it to an existing thread explicitly. +type ForwardMessageRequest struct { + To []string `json:"to" example:"alice@example.com"` + CC []string `json:"cc,omitempty" example:"bob@example.com"` + BCC []string `json:"bcc,omitempty" example:"carol@example.com"` + Body string `json:"body,omitempty" example:"FYI — see below"` + HTMLBody string `json:"html_body,omitempty" example:"

FYI — see below

"` + ConversationID string `json:"conversation_id,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` +} // @name ForwardMessageRequest + // ListMessagesResponse wraps the message list with pagination. type ListMessagesResponse struct { Messages []MessageSummary `json:"messages"` @@ -323,7 +338,7 @@ type PendingMessageSummary struct { AgentID string `json:"agent_id" example:"my-bot@example.com"` Direction string `json:"direction" example:"outbound"` Subject string `json:"subject" example:"Re: contract details"` - Type string `json:"type,omitempty" example:"send" enums:"send,reply,test"` + Type string `json:"type,omitempty" example:"send" enums:"send,reply,test,forward"` ConversationID string `json:"conversation_id,omitempty"` To []string `json:"to" example:"alice@example.com"` CC []string `json:"cc,omitempty"` diff --git a/internal/agent/forward_api_test.go b/internal/agent/forward_api_test.go new file mode 100644 index 0000000..98b8b1a --- /dev/null +++ b/internal/agent/forward_api_test.go @@ -0,0 +1,280 @@ +package agent_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/Mnexa-AI/e2a/internal/identity" +) + +// rawInboundForForwardTest builds a minimal RFC 5322 message that the +// forward handler's MIME extractor can parse. Includes a Subject so the +// composed forward subject can be asserted. +func rawInboundForForwardTest(from, to, subject, body string) []byte { + return []byte("From: " + from + "\r\n" + + "To: " + to + "\r\n" + + "Subject: " + subject + "\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + body) +} + +func TestForwardMessageUnauthorized(t *testing.T) { + server, _, _ := setupAPI(t) + + req, _ := http.NewRequest("POST", server.URL+"/api/v1/agents/any@example.com/messages/msg_123/forward", + bytes.NewBufferString(`{"to":["alice@example.com"]}`)) + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != 401 { + t.Errorf("status = %d, want 401", resp.StatusCode) + } +} + +func TestForwardMessageNotFound(t *testing.T) { + server, store, _ := setupAPI(t) + ctx := context.Background() + + user, _ := store.CreateOrGetUser(ctx, "owner-fwd-notfound@example.com", "Owner", "google-fwd-notfound") + apiKey, _ := store.CreateAPIKey(ctx, user.ID, "fwd-notfound-key", nil) + store.ClaimOrCreateDomain(ctx, "fwd-notfound.example.com", user.ID) + store.VerifyDomain(ctx, "fwd-notfound.example.com", user.ID) + store.CreateAgent(ctx, "agent@fwd-notfound.example.com", "fwd-notfound.example.com", "", "https://example.com/webhook", "", user.ID) + + req, _ := http.NewRequest("POST", + server.URL+"/api/v1/agents/agent@fwd-notfound.example.com/messages/msg_nonexistent/forward", + bytes.NewBufferString(`{"to":["alice@example.com"]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey.PlaintextKey) + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != 404 { + t.Errorf("status = %d, want 404", resp.StatusCode) + } +} + +func TestForwardMessageWrongAgent(t *testing.T) { + server, store, _ := setupAPI(t) + ctx := context.Background() + + userA, _ := store.CreateOrGetUser(ctx, "owner-fwd-a@example.com", "OwnerA", "google-fwd-a") + store.ClaimOrCreateDomain(ctx, "fwd-a.example.com", userA.ID) + store.VerifyDomain(ctx, "fwd-a.example.com", userA.ID) + agentA, _ := store.CreateAgent(ctx, "agent@fwd-a.example.com", "fwd-a.example.com", "", "https://example.com/webhook", "", userA.ID) + + userB, _ := store.CreateOrGetUser(ctx, "owner-fwd-b@example.com", "OwnerB", "google-fwd-b") + apiKeyB, _ := store.CreateAPIKey(ctx, userB.ID, "fwd-b-key", nil) + store.ClaimOrCreateDomain(ctx, "fwd-b.example.com", userB.ID) + store.VerifyDomain(ctx, "fwd-b.example.com", userB.ID) + store.CreateAgent(ctx, "agent@fwd-b.example.com", "fwd-b.example.com", "", "https://example.com/webhook", "", userB.ID) + + msg, _ := store.CreateInboundMessage(ctx, "", agentA.ID, "alice@gmail.com", "bot@fwd-a.example.com", "", "Hello", "", "", nil, nil, nil, nil, nil) + + req, _ := http.NewRequest("POST", + server.URL+"/api/v1/agents/agent@fwd-b.example.com/messages/"+msg.ID+"/forward", + bytes.NewBufferString(`{"to":["alice@example.com"]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKeyB.PlaintextKey) + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != 404 { + t.Errorf("status = %d, want 404 (wrong agent)", resp.StatusCode) + } +} + +func TestForwardMessageUnverifiedDomain(t *testing.T) { + server, store, _ := setupAPI(t) + ctx := context.Background() + + user, _ := store.CreateOrGetUser(ctx, "owner-fwd-unverified@example.com", "Owner", "google-fwd-unverified") + apiKey, _ := store.CreateAPIKey(ctx, user.ID, "fwd-unverified-key", nil) + store.ClaimOrCreateDomain(ctx, "fwd-unverified.example.com", user.ID) + agent, _ := store.CreateAgent(ctx, "agent@fwd-unverified.example.com", "fwd-unverified.example.com", "", "https://example.com/webhook", "", user.ID) + + msg, _ := store.CreateInboundMessage(ctx, "", agent.ID, "alice@gmail.com", "agent@fwd-unverified.example.com", "", "Hello", "", "", nil, nil, nil, nil, nil) + + req, _ := http.NewRequest("POST", + server.URL+"/api/v1/agents/agent@fwd-unverified.example.com/messages/"+msg.ID+"/forward", + bytes.NewBufferString(`{"to":["alice@example.com"]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey.PlaintextKey) + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != 403 { + t.Errorf("status = %d, want 403", resp.StatusCode) + } +} + +func TestForwardMessageMissingRecipients(t *testing.T) { + server, store, _ := setupAPI(t) + ctx := context.Background() + + user, _ := store.CreateOrGetUser(ctx, "owner-fwd-norecip@example.com", "Owner", "google-fwd-norecip") + apiKey, _ := store.CreateAPIKey(ctx, user.ID, "fwd-norecip-key", nil) + store.ClaimOrCreateDomain(ctx, "fwd-norecip.example.com", user.ID) + store.VerifyDomain(ctx, "fwd-norecip.example.com", user.ID) + agent, _ := store.CreateAgent(ctx, "agent@fwd-norecip.example.com", "fwd-norecip.example.com", "", "https://example.com/webhook", "", user.ID) + + msg, _ := store.CreateInboundMessage(ctx, "", agent.ID, "alice@gmail.com", "agent@fwd-norecip.example.com", "", "Hello", "", "", nil, nil, nil, nil, nil) + + req, _ := http.NewRequest("POST", + server.URL+"/api/v1/agents/agent@fwd-norecip.example.com/messages/"+msg.ID+"/forward", + bytes.NewBufferString(`{"body":"fyi"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey.PlaintextKey) + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != 400 { + t.Errorf("status = %d, want 400", resp.StatusCode) + } +} + +func TestForwardMessageViaSMTP(t *testing.T) { + server, store, _, smtpDone := setupAPIWithSMTP(t) + ctx := context.Background() + + user, _ := store.CreateOrGetUser(ctx, "owner-fwd-smtp@example.com", "Owner", "google-fwd-smtp") + apiKey, _ := store.CreateAPIKey(ctx, user.ID, "fwd-smtp-key", nil) + store.ClaimOrCreateDomain(ctx, "fwd-smtp.example.com", user.ID) + store.VerifyDomain(ctx, "fwd-smtp.example.com", user.ID) + agent, _ := store.CreateAgent(ctx, "bot@fwd-smtp.example.com", "fwd-smtp.example.com", "", "https://example.com/webhook", "", user.ID) + + raw := rawInboundForForwardTest("alice@gmail.com", "bot@fwd-smtp.example.com", "Original Subject", "original body content") + msg, _ := store.CreateInboundMessage(ctx, "", agent.ID, "alice@gmail.com", "bot@fwd-smtp.example.com", "", "Original Subject", "", "", raw, nil, nil, nil, nil) + + payload := `{"to":["destination@example.com"],"body":"FYI — see below"}` + req, _ := http.NewRequest("POST", + server.URL+"/api/v1/agents/bot@fwd-smtp.example.com/messages/"+msg.ID+"/forward", + bytes.NewBufferString(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey.PlaintextKey) + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var result map[string]string + json.NewDecoder(resp.Body).Decode(&result) + if result["status"] != "sent" { + t.Errorf("status = %q, want sent", result["status"]) + } + if result["method"] != "smtp" { + t.Errorf("method = %q, want smtp", result["method"]) + } + + msgs := smtpDone() + if len(msgs) != 1 { + t.Fatalf("expected 1 SMTP message, got %d", len(msgs)) + } + if msgs[0].To != "destination@example.com" { + t.Errorf("SMTP To = %q, want destination@example.com (the forward target, NOT the original sender)", msgs[0].To) + } + if !strings.Contains(msgs[0].Data, "Subject: Fwd: Original Subject") { + t.Errorf("SMTP body missing Subject prefix Fwd:\nbody snippet: %s", firstNLines(msgs[0].Data, 20)) + } + if !strings.Contains(msgs[0].Data, "FYI — see below") { + t.Errorf("SMTP body missing caller comment") + } + if !strings.Contains(msgs[0].Data, "---------- Forwarded message ---------") { + t.Errorf("SMTP body missing forwarded divider") + } + if !strings.Contains(msgs[0].Data, "From: alice@gmail.com") { + t.Errorf("SMTP body missing original From in quoted block") + } + if !strings.Contains(msgs[0].Data, "original body content") { + t.Errorf("SMTP body missing original body content") + } + // Forward must NOT inherit reply threading headers + if strings.Contains(msgs[0].Data, "In-Reply-To:") { + t.Errorf("SMTP body unexpectedly contains In-Reply-To header — forward should be a new thread") + } +} + +func TestForwardMessageHITLHolds(t *testing.T) { + server, store, pool, smtpDone := setupAPIWithSMTP(t) + ctx := context.Background() + + user, _ := store.CreateOrGetUser(ctx, "owner-fwd-hitl@example.com", "Owner", "google-fwd-hitl") + apiKey, _ := store.CreateAPIKey(ctx, user.ID, "fwd-hitl-key", nil) + store.ClaimOrCreateDomain(ctx, "fwd-hitl.example.com", user.ID) + store.VerifyDomain(ctx, "fwd-hitl.example.com", user.ID) + agent, _ := store.CreateAgent(ctx, "bot@fwd-hitl.example.com", "fwd-hitl.example.com", "", "https://example.com/webhook", "", user.ID) + enableHITL(t, store, agent.ID, user.ID) + + raw := rawInboundForForwardTest("alice@gmail.com", "bot@fwd-hitl.example.com", "Original", "body") + msg, _ := store.CreateInboundMessage(ctx, "", agent.ID, "alice@gmail.com", "bot@fwd-hitl.example.com", "", "Original", "", "", raw, nil, nil, nil, nil) + + payload := `{"to":["dest@example.com"],"body":"please review"}` + req, _ := http.NewRequest("POST", + server.URL+"/api/v1/agents/bot@fwd-hitl.example.com/messages/"+msg.ID+"/forward", + bytes.NewBufferString(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey.PlaintextKey) + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("status = %d, want 202", resp.StatusCode) + } + + var body struct { + Status string `json:"status"` + MessageID string `json:"message_id"` + } + json.NewDecoder(resp.Body).Decode(&body) + if body.Status != "pending_approval" { + t.Errorf("status = %q, want pending_approval", body.Status) + } + + // SMTP must not have been touched. + if msgs := smtpDone(); len(msgs) != 0 { + t.Fatalf("expected 0 SMTP messages, got %d", len(msgs)) + } + + // DB row must have type="forward" and persist the original + // email_message_id (so the reviewer can see what's being forwarded + // via InboundContext). + var ( + status, subject string + msgType, emid *string + ) + err := pool.QueryRow(ctx, + `SELECT status, subject, message_type, email_message_id + FROM messages WHERE id = $1`, body.MessageID, + ).Scan(&status, &subject, &msgType, &emid) + if err != nil { + t.Fatalf("read pending row: %v", err) + } + if status != identity.MessageStatusPendingApproval { + t.Errorf("status = %q, want pending_approval", status) + } + if subject != "Fwd: Original" { + t.Errorf("subject = %q, want %q", subject, "Fwd: Original") + } + if msgType == nil || *msgType != "forward" { + t.Errorf("message_type = %v, want forward", msgType) + } + if emid == nil || *emid != "" { + t.Errorf("email_message_id = %v, want ", emid) + } +} + +func firstNLines(s string, n int) string { + lines := strings.SplitN(s, "\n", n+1) + if len(lines) > n { + lines = lines[:n] + } + return strings.Join(lines, "\n") +} diff --git a/internal/agent/hitl_api.go b/internal/agent/hitl_api.go index 21d001b..9bd4c30 100644 --- a/internal/agent/hitl_api.go +++ b/internal/agent/hitl_api.go @@ -48,12 +48,12 @@ type pendingMessageDetail struct { RejectionReason string `json:"rejection_reason,omitempty"` ProviderMessageID string `json:"provider_message_id,omitempty"` Method string `json:"method,omitempty"` - // InboundContext is attached only when this is a reply (i.e. when - // email_message_id is non-empty and the inbound row is still in - // retention). Used by the review panel to render the "In reply to" - // pane with SPF/DKIM/DMARC provenance — without it, reviewers can't - // see what the original message looked like or whether it was - // auth-validated. + // InboundContext is attached when this is a reply or a forward + // (i.e. when email_message_id is non-empty and the parent inbound + // row is still in retention). Used by the review panel to render + // the "In reply to" / "Forwarding" pane with SPF/DKIM/DMARC + // provenance — without it, reviewers can't see what the original + // message looked like or whether it was auth-validated. InboundContext *pendingMessageInboundContext `json:"inbound,omitempty"` } @@ -449,6 +449,13 @@ type hitlInboundLookup interface { // buildSendRequestFromMessage reconstructs a SendRequest from a stored // pending-approval message (with any reviewer edits already applied). +// +// ReplyToMessageID is only copied through for type="reply". Forwards +// also persist email_message_id (so the review panel can render the +// "what's being forwarded" pane via InboundContext), but a forward must +// ship as a new thread — copying email_message_id into ReplyToMessageID +// would emit In-Reply-To/References on the outbound and stitch the +// forward into the original thread. func buildSendRequestFromMessage(m *identity.Message) (outbound.SendRequest, error) { var attachments []outbound.Attachment if len(m.AttachmentsJSON) > 0 { @@ -456,6 +463,10 @@ func buildSendRequestFromMessage(m *identity.Message) (outbound.SendRequest, err return outbound.SendRequest{}, err } } + replyToMessageID := "" + if m.Type == "reply" { + replyToMessageID = m.EmailMessageID + } return outbound.SendRequest{ To: m.ToRecipients, CC: m.CC, @@ -463,7 +474,7 @@ func buildSendRequestFromMessage(m *identity.Message) (outbound.SendRequest, err Subject: m.Subject, Body: m.BodyText, HTMLBody: m.BodyHTML, - ReplyToMessageID: m.EmailMessageID, + ReplyToMessageID: replyToMessageID, ConversationID: m.ConversationID, Attachments: attachments, }, nil diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index a4b39ac..b9c706d 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -68,8 +68,8 @@ func TestInboundDelivered(t *testing.T) { if p.Body.From != "alice@gmail.com" { t.Errorf("From = %q", p.Body.From) } - if p.Body.To != "agent@inbound.example.com" { - t.Errorf("To = %q", p.Body.To) + if len(p.Body.To) != 1 || p.Body.To[0] != "agent@inbound.example.com" { + t.Errorf("To = %v, want [agent@inbound.example.com]", p.Body.To) } // Domain-Check header should be present @@ -242,12 +242,12 @@ func TestPollMode_E2E(t *testing.T) { var listResp struct { Messages []struct { - MessageID string `json:"message_id"` - From string `json:"from"` - To string `json:"to"` - Subject string `json:"subject"` - Status string `json:"status"` - ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Status string `json:"status"` + ConversationID string `json:"conversation_id"` } `json:"messages"` HasMore bool `json:"has_more"` } @@ -278,7 +278,7 @@ func TestPollMode_E2E(t *testing.T) { var msgResp struct { MessageID string `json:"message_id"` From string `json:"from"` - To string `json:"to"` + To []string `json:"to"` AuthHeaders map[string]string `json:"auth_headers"` RawMessage string `json:"raw_message"` ConversationID string `json:"conversation_id"` diff --git a/internal/outbound/forward.go b/internal/outbound/forward.go new file mode 100644 index 0000000..0444b28 --- /dev/null +++ b/internal/outbound/forward.go @@ -0,0 +1,260 @@ +package outbound + +import ( + "bytes" + "encoding/base64" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "strings" +) + +// ForwardContext captures the header + body fields from an inbound message +// that a forward should quote. Headers are kept as raw strings (no +// re-parsing) so the quoted block renders the same lexical text the +// original sender chose, including display names. Text/HTML are +// best-effort decoded from the raw MIME — empty strings on parse +// failure so the forward still ships with the header block. +type ForwardContext struct { + From string + Date string + Subject string + To string + Cc string + Text string + HTML string +} + +// ExtractForwardContext parses an RFC 5322 raw message and pulls out the +// fields needed to compose a forward quote. Parse failures degrade +// gracefully — the returned context's body fields stay empty so the +// caller still gets a usable header block to prepend. +func ExtractForwardContext(rawMessage []byte) ForwardContext { + ctx := ForwardContext{} + if len(rawMessage) == 0 { + return ctx + } + + msg, err := mail.ReadMessage(bytes.NewReader(rawMessage)) + if err != nil { + return ctx + } + + ctx.From = strings.TrimSpace(msg.Header.Get("From")) + ctx.Date = strings.TrimSpace(msg.Header.Get("Date")) + ctx.Subject = strings.TrimSpace(msg.Header.Get("Subject")) + ctx.To = strings.TrimSpace(msg.Header.Get("To")) + ctx.Cc = strings.TrimSpace(msg.Header.Get("Cc")) + + contentType := msg.Header.Get("Content-Type") + encoding := msg.Header.Get("Content-Transfer-Encoding") + ctx.Text, ctx.HTML = extractBodyParts(msg.Body, contentType, encoding) + return ctx +} + +// 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) { + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + // No Content-Type or malformed — fall through and treat as + // text/plain. Cheaper than refusing the forward. + mediaType = "text/plain" + params = nil + } + + if !strings.HasPrefix(mediaType, "multipart/") { + raw, err := io.ReadAll(body) + if err != nil { + return "", "" + } + decoded := decodeTransferEncoding(raw, encoding) + switch mediaType { + case "text/html": + return "", string(decoded) + default: + return string(decoded), "" + } + } + + boundary := params["boundary"] + if boundary == "" { + return "", "" + } + + mr := multipart.NewReader(body, boundary) + for { + part, err := mr.NextPart() + if err != nil { + break + } + partCT := part.Header.Get("Content-Type") + partEnc := part.Header.Get("Content-Transfer-Encoding") + partType, _, _ := mime.ParseMediaType(partCT) + + if strings.HasPrefix(partType, "multipart/") { + nestedText, nestedHTML := extractBodyParts(part, partCT, partEnc) + if textOut == "" { + textOut = nestedText + } + if htmlOut == "" { + htmlOut = nestedHTML + } + _ = part.Close() + continue + } + + raw, err := io.ReadAll(part) + _ = part.Close() + if err != nil { + continue + } + decoded := decodeTransferEncoding(raw, partEnc) + switch partType { + case "text/plain": + if textOut == "" { + textOut = string(decoded) + } + case "text/html": + if htmlOut == "" { + htmlOut = string(decoded) + } + } + if textOut != "" && htmlOut != "" { + break + } + } + return textOut, htmlOut +} + +// decodeTransferEncoding decodes the Content-Transfer-Encoding wrapping +// of a body part. Unknown encodings pass through unchanged — better to +// surface raw bytes than drop the part entirely. +func decodeTransferEncoding(data []byte, encoding string) []byte { + switch strings.ToLower(strings.TrimSpace(encoding)) { + case "quoted-printable": + decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(data))) + if err != nil { + return data + } + return decoded + case "base64": + decoded, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(data))) + if err != nil { + return data + } + return decoded + default: + return data + } +} + +// BuildForwardSubject prefixes "Fwd: " unless the subject already starts +// with Fwd:, Fw:, or Re:. The dedup avoids stacking on chains. Empty +// inputs produce "Fwd: (no subject)" so the recipient still sees the +// message is a forward. +func BuildForwardSubject(orig string) string { + trimmed := strings.TrimSpace(orig) + if trimmed == "" { + return "Fwd: (no subject)" + } + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") { + return trimmed + } + return "Fwd: " + trimmed +} + +// BuildForwardBody composes the text/plain body of a forward: the +// caller's optional comment, a Gmail-style divider, the original headers +// as a quote block, then the original text body if extraction +// succeeded. +func BuildForwardBody(comment string, ctx ForwardContext) string { + var buf strings.Builder + if c := strings.TrimRight(comment, "\r\n"); c != "" { + buf.WriteString(c) + buf.WriteString("\r\n\r\n") + } + buf.WriteString("---------- Forwarded message ---------\r\n") + writeHeaderLine(&buf, "From", ctx.From) + writeHeaderLine(&buf, "Date", ctx.Date) + writeHeaderLine(&buf, "Subject", ctx.Subject) + writeHeaderLine(&buf, "To", ctx.To) + writeHeaderLine(&buf, "Cc", ctx.Cc) + buf.WriteString("\r\n") + if ctx.Text != "" { + buf.WriteString(strings.ReplaceAll(ctx.Text, "\n", "\r\n")) + if !strings.HasSuffix(ctx.Text, "\n") { + buf.WriteString("\r\n") + } + } + return buf.String() +} + +// BuildForwardHTMLBody composes the text/html body of a forward. The +// caller's HTML comment is emitted as-is (the API contract treats +// html_body as caller-controlled markup); the forwarded block is wrapped +// in a blockquote so mail clients render it visually as a quote. +func BuildForwardHTMLBody(commentHTML string, ctx ForwardContext) string { + var buf strings.Builder + if c := strings.TrimSpace(commentHTML); c != "" { + buf.WriteString(c) + buf.WriteString("\r\n

\r\n") + } + buf.WriteString(`
`) + buf.WriteString("\r\n") + buf.WriteString("---------- Forwarded message ---------
\r\n") + writeHTMLHeaderLine(&buf, "From", ctx.From) + writeHTMLHeaderLine(&buf, "Date", ctx.Date) + writeHTMLHeaderLine(&buf, "Subject", ctx.Subject) + writeHTMLHeaderLine(&buf, "To", ctx.To) + writeHTMLHeaderLine(&buf, "Cc", ctx.Cc) + buf.WriteString("
\r\n") + if ctx.HTML != "" { + buf.WriteString(`
`) + buf.WriteString("\r\n") + buf.WriteString(ctx.HTML) + buf.WriteString("\r\n
") + } else if ctx.Text != "" { + buf.WriteString(`
`)
+		buf.WriteString(htmlEscape(ctx.Text))
+		buf.WriteString("
") + } + buf.WriteString("\r\n
") + return buf.String() +} + +func writeHeaderLine(buf *strings.Builder, name, value string) { + if value == "" { + return + } + buf.WriteString(name) + buf.WriteString(": ") + buf.WriteString(value) + buf.WriteString("\r\n") +} + +func writeHTMLHeaderLine(buf *strings.Builder, name, value string) { + if value == "" { + return + } + buf.WriteString("") + buf.WriteString(name) + buf.WriteString(": ") + buf.WriteString(htmlEscape(value)) + buf.WriteString("
\r\n") +} + +// htmlEscape is a tiny subset of html.EscapeString — sufficient for +// the four characters that can break a blockquote. Avoids pulling in +// html/template for one function. +func htmlEscape(s string) string { + if !strings.ContainsAny(s, "&<>\"") { + return s + } + return strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """).Replace(s) +} diff --git a/internal/outbound/forward_test.go b/internal/outbound/forward_test.go new file mode 100644 index 0000000..89eeefb --- /dev/null +++ b/internal/outbound/forward_test.go @@ -0,0 +1,211 @@ +package outbound + +import ( + "strings" + "testing" +) + +func TestBuildForwardSubject(t *testing.T) { + cases := []struct { + in, want string + }{ + {"Hello", "Fwd: Hello"}, + {" Hello ", "Fwd: Hello"}, + {"", "Fwd: (no subject)"}, + {"Fwd: Hello", "Fwd: Hello"}, + {"fwd: lower", "fwd: lower"}, + {"Fw: short form", "Fw: short form"}, + {"FW: caps", "FW: caps"}, + } + for _, c := range cases { + got := BuildForwardSubject(c.in) + if got != c.want { + t.Errorf("BuildForwardSubject(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestExtractForwardContext_TextPlain(t *testing.T) { + raw := []byte("From: alice@example.com\r\n" + + "To: agent@e2a.dev\r\n" + + "Subject: Hi\r\n" + + "Date: Mon, 1 Jan 2026 10:00:00 +0000\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "hello world") + + ctx := ExtractForwardContext(raw) + if ctx.From != "alice@example.com" { + t.Errorf("From = %q", ctx.From) + } + if ctx.Subject != "Hi" { + t.Errorf("Subject = %q", ctx.Subject) + } + if ctx.To != "agent@e2a.dev" { + t.Errorf("To = %q", ctx.To) + } + if ctx.Text != "hello world" { + t.Errorf("Text = %q", ctx.Text) + } + if ctx.HTML != "" { + t.Errorf("HTML = %q, want empty", ctx.HTML) + } +} + +func TestExtractForwardContext_MultipartAlternative(t *testing.T) { + raw := []byte("From: alice@example.com\r\n" + + "Subject: Hi\r\n" + + "Content-Type: multipart/alternative; boundary=\"BOUND\"\r\n" + + "\r\n" + + "--BOUND\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "plain body\r\n" + + "--BOUND\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "\r\n" + + "

html body

\r\n" + + "--BOUND--\r\n") + + ctx := ExtractForwardContext(raw) + if !strings.Contains(ctx.Text, "plain body") { + t.Errorf("Text = %q, want contains 'plain body'", ctx.Text) + } + if !strings.Contains(ctx.HTML, "

html body

") { + t.Errorf("HTML = %q, want contains '

html body

'", ctx.HTML) + } +} + +func TestExtractForwardContext_QuotedPrintable(t *testing.T) { + raw := []byte("From: alice@example.com\r\n" + + "Subject: Hi\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n" + + "\r\n" + + "caf=C3=A9") + + ctx := ExtractForwardContext(raw) + if ctx.Text != "café" { + t.Errorf("Text = %q, want 'café'", ctx.Text) + } +} + +func TestExtractForwardContext_MultipartMixedWrappingAlternative(t *testing.T) { + raw := []byte("From: alice@example.com\r\n" + + "Subject: Hi\r\n" + + "Content-Type: multipart/mixed; boundary=\"OUTER\"\r\n" + + "\r\n" + + "--OUTER\r\n" + + "Content-Type: multipart/alternative; boundary=\"INNER\"\r\n" + + "\r\n" + + "--INNER\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "inner text\r\n" + + "--INNER\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "\r\n" + + "

inner html

\r\n" + + "--INNER--\r\n" + + "--OUTER\r\n" + + "Content-Type: application/pdf\r\n" + + "Content-Disposition: attachment; filename=\"a.pdf\"\r\n" + + "\r\n" + + "PDFBINARY\r\n" + + "--OUTER--\r\n") + + ctx := ExtractForwardContext(raw) + if !strings.Contains(ctx.Text, "inner text") { + t.Errorf("Text = %q, want contains 'inner text'", ctx.Text) + } + if !strings.Contains(ctx.HTML, "inner html") { + t.Errorf("HTML = %q, want contains 'inner html'", ctx.HTML) + } +} + +func TestExtractForwardContext_MalformedReturnsEmpty(t *testing.T) { + ctx := ExtractForwardContext([]byte("not an email")) + if ctx.From != "" || ctx.Text != "" || ctx.HTML != "" { + t.Errorf("malformed input produced non-empty fields: %+v", ctx) + } +} + +func TestExtractForwardContext_EmptyInput(t *testing.T) { + ctx := ExtractForwardContext(nil) + if ctx != (ForwardContext{}) { + t.Errorf("empty input produced non-zero context: %+v", ctx) + } +} + +func TestBuildForwardBody_WithCommentAndContext(t *testing.T) { + ctx := ForwardContext{ + From: "alice@example.com", + Date: "Mon, 1 Jan 2026 10:00:00 +0000", + Subject: "Hi", + To: "agent@e2a.dev", + Text: "hello world", + } + body := BuildForwardBody("FYI", ctx) + wantSubstrings := []string{ + "FYI", + "---------- Forwarded message ---------", + "From: alice@example.com", + "Date: Mon, 1 Jan 2026 10:00:00 +0000", + "Subject: Hi", + "To: agent@e2a.dev", + "hello world", + } + for _, s := range wantSubstrings { + if !strings.Contains(body, s) { + t.Errorf("body missing %q\nfull body:\n%s", s, body) + } + } + if strings.Contains(body, "Cc:") { + t.Errorf("body has Cc line when ctx.Cc is empty") + } +} + +func TestBuildForwardBody_NoCommentNoOriginalBody(t *testing.T) { + ctx := ForwardContext{From: "alice@example.com", Subject: "Hi"} + body := BuildForwardBody("", ctx) + if !strings.HasPrefix(body, "---------- Forwarded message ---------") { + t.Errorf("body should start with divider when no comment, got: %q", body) + } +} + +func TestBuildForwardHTMLBody_PrefersHTMLOverText(t *testing.T) { + ctx := ForwardContext{ + From: "alice@example.com", + HTML: "

original html

", + Text: "plain fallback", + } + html := BuildForwardHTMLBody("

FYI

", ctx) + if !strings.Contains(html, "

original html

") { + t.Errorf("html body missing original HTML: %s", html) + } + if strings.Contains(html, "plain fallback") { + t.Errorf("html body shouldn't fall back to text when HTML present") + } + if !strings.Contains(html, "

FYI

") { + t.Errorf("html body missing caller comment") + } +} + +func TestBuildForwardHTMLBody_FallsBackToTextInPre(t *testing.T) { + ctx := ForwardContext{From: "alice@example.com", Text: "plain only"} + html := BuildForwardHTMLBody("", ctx) + if !strings.Contains(html, "
plain only
") { + t.Errorf("html body should wrap text fallback in
: %s", html)
+	}
+}
+
+func TestBuildForwardHTMLBody_EscapesHTMLSpecialsInHeaders(t *testing.T) {
+	ctx := ForwardContext{From: `"Eve " `, Subject: ""}
+	html := BuildForwardHTMLBody("", ctx)
+	if strings.Contains(html, "") {
+		t.Errorf("html body must escape