outbound

package
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

Documentation

Index

Constants

View Source
const MaxComposedMessageBytes = 10 * 1024 * 1024

MaxComposedMessageBytes is the hard cap on a composed outbound message — the sum of subject + text + html + DECODED attachment bytes. It is the SES v1 stored-message ceiling (the real upstream limit), distinct from the per-field (subject/body) and per-attachment caps: a caller can stay under every individual limit while the composed MIME still exceeds what the upstream provider accepts, so the true ceiling is checked on the fully-composed content. Over → 413 payload_too_large at the API edge.

View Source
const PlatformDisplayName = "e2a"

PlatformDisplayName is the display name on platform-originated mail (From: "e2a" <noreply@<from_domain>>). Matches the historical test-email identity so recipients/agents see the same sender across releases.

Variables

This section is empty.

Functions

func BuildForwardBody

func BuildForwardBody(comment string, ctx ForwardContext) string

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 BuildForwardHTMLBody

func BuildForwardHTMLBody(commentHTML string, ctx ForwardContext) 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 BuildForwardSubject

func BuildForwardSubject(orig string) string

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 BuildReferencesChain

func BuildReferencesChain(rawMessage []byte, parentMsgID string) []string

BuildReferencesChain returns the References chain to write on a reply, per RFC 5322 § 3.6.4:

  • If the parent has a References header, return: parent.References ++ [parentMsgID]
  • Else if the parent has an In-Reply-To header, return: parent.InReplyTo ++ [parentMsgID]
  • Else return: [parentMsgID] (the reply still has at least the parent in its chain)

parentMsgID must be the canonicalized RFC 5322 Message-ID of the inbound being replied to (i.e. the same value the caller passes as SendRequest.ReplyToMessageID for the In-Reply-To header). Empty input yields an empty chain — callers fall back to legacy single-id behavior.

This chain matters for multi-party threads where one participant's reply is delivered only to a subset of the recipients (e.g. agent-mediated scheduler scenarios). Without the full chain, a downstream reply-to-all has In-Reply-To pointing at a Message-ID that recipients outside the subset have never seen, and Gmail/other clients fork the thread.

func ComposeMessage

func ComposeMessage(from string, to []string, cc []string, subject, body, contentType, replyToMsgID string, references []string, fromDomain, replyTo, conversationID string) ([]byte, error)

ComposeMessage builds an RFC 2822 email message (single content type). Message-ID is omitted — SES assigns one on send. If to is empty, the To: header is omitted entirely (CC-only send). BCC is never written to headers — it is handled at the SMTP envelope level. When conversationID is non-empty, an X-E2A-Conversation-ID header is written so recipient agents on this platform can continue the same application thread without depending on In-Reply-To chains.

Threading headers (RFC 5322 § 3.6.4):

  • replyToMsgID is the immediate parent's Message-ID — written to In-Reply-To.
  • references is the FULL ancestor chain in conversation order (oldest → newest, including the immediate parent). When non-empty, written as the References header in space-separated form. When empty but replyToMsgID is set, References falls back to [replyToMsgID] for backwards compat.

Why the full chain matters: in multi-party email threads, some participants may not have seen every prior Message-ID (e.g. agent A replies only to agent B; agent B then replies-all back to user — user has no record of agent A's reply). Without the full References chain, the user's mail client (Gmail) can't anchor the reply to the existing thread and forks a new one. With the full chain, the client matches on ANY prior ID and threads correctly.

func ComposeMessageWithAttachments

func ComposeMessageWithAttachments(from string, to []string, cc []string, subject, textBody, htmlBody, replyToMsgID string, references []string, fromDomain, replyTo, conversationID string, attachments []Attachment) ([]byte, error)

ComposeMessageWithAttachments builds an RFC 2822 multipart/mixed email with attachments. If no attachments are provided, falls back to ComposeMultipartMessage. See ComposeMessage for replyToMsgID / references semantics.

func ComposeMultipartMessage

func ComposeMultipartMessage(from string, to []string, cc []string, subject, textBody, htmlBody, replyToMsgID string, references []string, fromDomain, replyTo, conversationID string) ([]byte, error)

ComposeMultipartMessage builds an RFC 2822 multipart/alternative email with text and HTML parts. If htmlBody is empty, falls back to a single text/plain message via ComposeMessage. See ComposeMessage for replyToMsgID / references semantics.

func ComposedSize

func ComposedSize(subject, text, html string, atts []Attachment) int

ComposedSize returns the composed byte total of an outbound message: the sum of subject + text + html + DECODED attachment bytes. Attachment Data is base64; embedded whitespace (CR/LF, spaces, tabs) is stripped before decoding and a decode failure falls back to the raw wire length so the total is never under-counted.

This is the single source of truth for the composed-message ceiling, shared by the direct send path (httpapi send/reply/forward) and the HITL approve- override path (agent) so neither entry point can bypass the cap.

func DecodeAttachmentData

func DecodeAttachmentData(data string) ([]byte, error)

DecodeAttachmentData decodes a base64-encoded attachment data string.

func IsComposedSizeError

func IsComposedSizeError(err error) bool

func IsConnectionError

func IsConnectionError(err error) bool

IsConnectionError reports whether err is a provider-CONNECTION failure (the relay is unreachable / misconfigured) rather than a per-message SMTP rejection. The River worker snoozes these (design §8 circuit breaker): a regional SES/SNS outage should DEFER the whole queue without spending each job's retry budget and mass-firing false email.failed, whereas a per-message 4xx/5xx uses the bounded-retry / terminal path.

CRITICAL: an error carrying an SMTP code is a per-message verdict, NEVER an outage — even if its text contains "tls"/"timeout"/"eof" (e.g. "550 5.7.1 TLS required"). We check the code first and bail; only codeless network-level failures (dial/timeout/reset/refused, or our own not-configured relay) are outages. (Earlier substring matching mis-classified a permanent 5xx-with-"tls" as a 72h outage snooze — adversarial review of #388.)

func IsPermanentSMTPError

func IsPermanentSMTPError(err error) bool

IsPermanentSMTPError reports whether err is a DEFINITELY-permanent SMTP failure — a 5xx response (recipient rejected, message refused) that must not be retried. The River worker's deliverer uses this to set DeliverOutcome.Permanent.

It is deliberately conservative: ONLY a 5xx is permanent. Connection errors (dial timeout, connection refused), 4xx, and any unclassified error are NOT permanent — they retry. This matters for at-least-once: mis-classifying a transient network error as permanent would terminal-fail a send that should retry until the provider accepts. (Provider-outage snooze — deferring an outage instead of spending retries — is slice D.)

func IsTransientSMTPError

func IsTransientSMTPError(err error) bool

IsTransientSMTPError reports whether err is a retryable SMTP failure (4xx / throttle) vs a permanent one. Exported so the River worker's deliverer can set DeliverOutcome.Permanent. Nil is not transient.

func IsValidationError

func IsValidationError(err error) bool

IsValidationError returns true if err is a ValidationError.

func NormalizeRecipients

func NormalizeRecipients(agent *identity.AgentIdentity, fromDomain string, req SendRequest) ([]string, []string, []string, []string, error)

NormalizeRecipients is the single canonical envelope resolver used both for managed-unsubscribe binding and final MIME composition.

func PlatformEnvelopeFrom

func PlatformEnvelopeFrom(fromDomain string) string

PlatformEnvelopeFrom returns the platform/noreply sender address for a deployment's outbound from-domain. Single source of truth for the "noreply@<from_domain>" identity used by platform-originated messages.

func SuppressionRemediation

func SuppressionRemediation(agentID string) string

SuppressionRemediation returns the shared operator guidance for an address blocked by either account-wide or exact-agent suppression scope.

Types

type Attachment

type Attachment struct {
	Filename    string `json:"filename" example:"report.pdf"`
	ContentType string `json:"content_type" example:"application/pdf"`
	Data        string `` // base64-encoded
	/* 193-byte string literal not displayed */

} // @name Attachment

Attachment is a base64-encoded file attachment.

func ForwardAttachments

func ForwardAttachments(rawMessage []byte) []Attachment

ForwardAttachments extracts the stored attachment parts of the message being forwarded and converts them to the outbound Attachment shape (base64-encoded data) so a forward carries the original files by default, the way mail clients do — the server already holds the bytes, so no client round-trip is needed. Order matches the source message's authoritative attachment index. Returns nil when the source has no attachments.

type ComposeResult

type ComposeResult struct {
	EnvelopeFrom string
	Recipients   []string // envelope (to+cc+bcc) for RCPT TO
	SentAs       string
	Method       string // always "smtp"
	Raw          []byte // Sent-folder bytes (DKIM-signed, no SES header)
	To, CC, BCC  []string
}

ComposeResult is the public view of a composed-but-not-yet-sent message, for the async accept path (internal/agent's DeliverOutbound). The accept-tx persists Raw as messages.raw_message and EnvelopeFrom/SentAs on the row; the River worker (internal/outboundsend) reloads them and submits via SubmitOnce. Raw is the Sent-folder copy (no SES config-set header) — SubmitOnce re-attaches that header at submit time, exactly as Send does, so the recipient copy and the stored copy stay identical to the synchronous path.

type ComposedSizeError

type ComposedSizeError struct {
	ActualBytes int
	MaxBytes    int
}

ComposedSizeError reports the final, post-feature-composition size. It is distinct from ordinary request validation so API layers can preserve the canonical 413 payload_too_large contract.

func (*ComposedSizeError) Error

func (e *ComposedSizeError) Error() string

type DKIMKeyLookup

type DKIMKeyLookup interface {
	GetDKIMKeyInternal(ctx context.Context, domain string) (selector string, privateKey []byte, err error)
}

DKIMKeyLookup returns the DKIM selector and PKCS#1 DER private key bytes for a domain. Empty selector OR empty key means "no key available — skip signing". Implementations should NOT return an error for the not-found case; that's a normal flow during the migration window when older domains haven't been keyed yet.

Method name carries the "Internal" suffix to flag the boundary: this is NOT user-input-safe. The caller must have already authenticated and authorized the from-domain (e.g. via the agent layer's ownership check on the sender). A handler that ever calls this with a user-supplied domain string becomes a "sign as anyone" primitive.

type ForwardContext

type ForwardContext struct {
	From    string
	Date    string
	Subject string
	To      string
	Cc      string
	Text    string
	HTML    string
}

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.

func ExtractForwardContext

func ExtractForwardContext(rawMessage []byte) ForwardContext

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.

type ReplyRecipients

type ReplyRecipients struct {
	To []string
	CC []string
}

ReplyRecipients holds the resolved To and CC lists for a reply.

func ParseReplyRecipients

func ParseReplyRecipients(rawMessage []byte, replyAll bool, extraCC []string) (*ReplyRecipients, error)

ParseReplyRecipients resolves reply recipients from a raw inbound email.

Reply To is determined by Reply-To if present, otherwise From — both parsed as address lists. All parsed mailboxes become To recipients.

If replyAll is true, original To and CC recipients are added to CC. The agent's own address is excluded from all fields. Explicit extraCC addresses are merged into CC.

Normalization, deduplication, and self-removal are handled downstream by Sender.Send(). This function does best-effort parsing and returns raw lowercase addresses.

func ReplyRecipientsForOutbound

func ReplyRecipientsForOutbound(origTo, origCC, extraCC []string, replyAll bool) *ReplyRecipients

ReplyRecipientsForOutbound resolves reply recipients for a message the agent itself SENT. Unlike ParseReplyRecipients (which targets the original sender via Reply-To/From), a reply to your own message continues the thread to the people you sent it to: To = the original To, and — on reply_all — the original Cc is added to Cc. The original Bcc is deliberately never carried (a reply must not re-expose blind recipients). Explicit extraCC from the request is merged into Cc. The values come from the stored outbound row's recipient columns, which are the authoritative record of what was sent.

Downstream Sender.Send() still normalizes, dedupes, and strips the agent's own address, so this returns the recipients as-stored.

type SMTPRelay

type SMTPRelay struct {
	// contains filtered or unexported fields
}

func NewSMTPRelay

func NewSMTPRelay(cfg *config.OutboundSMTPConfig) *SMTPRelay

func (*SMTPRelay) Configured

func (r *SMTPRelay) Configured() bool

func (*SMTPRelay) Send

func (r *SMTPRelay) Send(from string, recipients []string, message []byte) (string, error)

Send sends an email to one or more recipients and returns the Message-ID assigned by the remote server (e.g. SES).

func (*SMTPRelay) SendOnce

func (r *SMTPRelay) SendOnce(envelopeFrom string, recipients []string, message []byte) (string, error)

SendOnce performs a SINGLE SMTP submit — no internal retry loop — and returns the provider Message-ID. This is the entry point for the River outbound worker (internal/outboundsend), which owns the retry envelope: River reschedules the next attempt per the worker's NextRetry, so the relay must NOT loop (a loop here would hide the envelope from river_job and make each Work() run up to ~6.5 min). Classify the returned error with IsTransientSMTPError — transient (4xx/throttle) → let River retry; permanent (5xx/validation) → fail the message terminally.

func (*SMTPRelay) SendWithContext

func (r *SMTPRelay) SendWithContext(ctx context.Context, from string, recipients []string, message []byte) (string, error)

SendWithContext sends an email while honoring ctx during SMTP I/O and retry backoff. It is intended for request-bound callers that cannot allow the relay's normal retry envelope to outlive the request budget.

func (*SMTPRelay) SendWithEnvelope

func (r *SMTPRelay) SendWithEnvelope(envelopeFrom string, recipients []string, message []byte) (string, error)

SendWithEnvelope sends an email using envelopeFrom for SMTP MAIL FROM. Issues RCPT TO for each recipient. If any RCPT TO is rejected, the transaction is aborted. Returns the Message-ID assigned by the remote SMTP server from the DATA response. Retries transient SMTP errors (4xx) up to 3 times with backoff.

func (*SMTPRelay) SendWithEnvelopeContext

func (r *SMTPRelay) SendWithEnvelopeContext(ctx context.Context, envelopeFrom string, recipients []string, message []byte) (string, error)

SendWithEnvelopeContext is SendWithEnvelope with caller-controlled cancellation and deadline propagation.

type SendRequest

type SendRequest struct {
	From     string   `json:"from,omitempty"`
	To       []string `json:"to"`
	CC       []string `json:"cc,omitempty"`
	BCC      []string `json:"bcc,omitempty"`
	Subject  string   `json:"subject"`
	Body     string   `json:"body"`
	HTMLBody string   `json:"html_body,omitempty"`
	// ReplyTo, when set, overrides the Reply-To header (which otherwise
	// defaults to the agent's own address). Replies land here instead of at the
	// From address. Must be a single RFC 5322 address, optionally with a display
	// name; validated at the API edge.
	ReplyTo          string              `json:"reply_to,omitempty"`
	ReplyToMessageID string              `json:"reply_to_message_id"`
	References       []string            `json:"references,omitempty"`
	ConversationID   string              `json:"conversation_id,omitempty"`
	Attachments      []Attachment        `json:"attachments,omitempty"`
	Unsubscribe      *UnsubscribeOptions `json:"unsubscribe,omitempty"`
}

SendRequest is the outbound email contract.

References is the full ancestor Message-ID chain for a reply, oldest → newest. When non-empty, it is written verbatim into the References: header so receiving mail clients can anchor the reply to an existing thread by matching ANY id in the chain — required for multi-party threads where the immediate-parent Message-ID may not be in every participant's mailbox. When empty but ReplyToMessageID is set, the References header falls back to a single id (legacy behavior).

type SendResult

type SendResult struct {
	MessageID string   `json:"message_id"`
	Method    string   `json:"method"`  // "smtp"
	SentAs    string   `json:"sent_as"` // "own_address" | "relay" (decision 4 fallback)
	To        []string `json:"-"`       // canonicalized To recipients
	CC        []string `json:"-"`       // canonicalized CC recipients
	BCC       []string `json:"-"`       // canonicalized BCC recipients
	// Raw is the exact composed MIME placed on the wire (post-DKIM, post-SES
	// header). Persisted as messages.raw_message so the agent gets a readable
	// "Sent folder" — a mailbox keeps both sides of a conversation.
	Raw []byte `json:"-"`
}

SendResult contains the result of a successful send, including the canonicalized recipient lists for persistence.

type Sender

type Sender struct {
	// contains filtered or unexported fields
}

func NewSender

func NewSender(smtpRelay *SMTPRelay, fromDomain string) *Sender

func NewSenderWithDKIM

func NewSenderWithDKIM(smtpRelay *SMTPRelay, fromDomain string, dkimLookup DKIMKeyLookup) *Sender

NewSenderWithDKIM is NewSender with per-domain DKIM signing enabled. The lookup is queried once per send; key misses silently skip signing rather than fail the send.

func (*Sender) ComposeForAccept

func (s *Sender) ComposeForAccept(agent *identity.AgentIdentity, req SendRequest) (*ComposeResult, error)

ComposeForAccept composes an outbound message for the async accept path WITHOUT submitting it. The accept-tx persists the returned bytes + envelope so the River worker owns the actual SMTP submit; it reuses Send's exact compose stage (same recipient normalization, From gating, DKIM) so sync and async are wire-identical.

func (*Sender) ComposePlatformForAccept

func (s *Sender) ComposePlatformForAccept(req SendRequest) (*ComposeResult, error)

ComposePlatformForAccept composes a PLATFORM-originated outbound message for the async accept path WITHOUT submitting it. Unlike ComposeForAccept, the sender identity is the platform itself — From: "e2a" <noreply@<from_domain>> with a matching envelope MAIL FROM — never an agent, and the agent's own address is NOT stripped from the recipient set. This is what the agent test send (POST /v1/agents/{email}/test) requires: the message must traverse the real external SMTP → inbound route back to the agent's own public address, so both the agent-identity compose (which strips the agent's own address — "no valid recipients") and the local self-send loopback would defeat it.

The returned bytes flow through the identical durable pipeline as every other accepted send: the accept-tx persists Raw/EnvelopeFrom/SentAs and the River worker submits via SubmitOnce (which re-attaches the SES config-set header, exactly as with ComposeForAccept output).

DKIM: when a key exists for the platform from-domain it is applied, matching what relay-From agent sends do; absent a stored key the message goes out unsigned here and relies on the relay/SES edge signing — the same behavior the legacy synchronous test send had.

func (*Sender) Send

func (s *Sender) Send(agent *identity.AgentIdentity, req SendRequest) (*SendResult, error)

Send normalizes recipients, composes, and sends an email via SMTP relay (the historical retrying submit). Returns a ValidationError for caller errors (bad addresses, no visible recipients) and a plain error for transport failures.

func (*Sender) SendOnce

func (s *Sender) SendOnce(agent *identity.AgentIdentity, req SendRequest) (*SendResult, error)

SendOnce is Send with a SINGLE SMTP submit and no internal retry loop — the entry point for a caller that owns its own retry envelope. Behaviorally identical to Send except it calls smtpRelay.SendOnce. (The async pipeline does NOT use this — it persists ComposeForAccept's bytes and the River worker submits them via SubmitOnce — but it is the direct single-attempt analogue.)

func (*Sender) SetSESConfigurationSet

func (s *Sender) SetSESConfigurationSet(name string)

SetSESConfigurationSet enables SES event publishing for outbound mail by tagging each message with the given configuration set. Optional-setter pattern; empty leaves event publishing off.

func (*Sender) SetSendingStatusLookup

func (s *Sender) SetSendingStatusLookup(l SendingStatusLookup)

SetSendingStatusLookup enables own-address From for sending-verified domains. Optional-setter pattern (cf. relay.SetOutbox) so existing NewSender/NewSenderWithDKIM call sites and tests are unaffected.

func (*Sender) SubmitOnce

func (s *Sender) SubmitOnce(messageID, envelopeFrom string, recipients []string, sentBody []byte) (string, error)

SubmitOnce submits the persisted Sent-folder bytes in a SINGLE SMTP attempt (River owns retries) and returns the provider Message-ID. It attaches two wire-time headers post-DKIM (never in the signed header set):

  • X-E2A-Message-ID (delivery.MessageIDHeader) — the stable e2a correlation marker (async-send-contract §3.1). SES overrides supplied Message-ID/Date headers, but echoes original headers back in its notifications (mail.headers, when "include original headers" is enabled on the configuration set), so this is the value that correlates feedback for the SMTP-accept↔mark-sent crash window. Unlike the config-set header SES does NOT strip it — recipients see it too; it is deliberately a stable public marker. Stamped at submit time (not compose time) so messages accepted before this header existed still carry it on re-drive.

  • X-SES-CONFIGURATION-SET — re-attached because raw_message is stored WITHOUT it (SES strips it before delivery; the recipient/Sent-folder copy must not carry it).

Keeping the header logic here (not in the worker) means Send and the async path share one source of truth for what SES actually receives.

type SendingStatusLookup

type SendingStatusLookup interface {
	GetSendingStatus(ctx context.Context, domain string) (string, error)
}

SendingStatusLookup returns a domain's sending_status string ("none"|"pending"|"verified"|"failed"). *identity.Store satisfies it. Kept as a string interface so outbound does not import senderidentity (and its River + AWS SDK deps).

type UnsubscribeOptions

type UnsubscribeOptions struct {
	Mode string `json:"mode"`
	URL  string `json:"-"`
}

func ManagedUnsubscribeIntent

func ManagedUnsubscribeIntent(enabled bool) *UnsubscribeOptions

ManagedUnsubscribeIntent reconstructs the unresolved composition intent persisted on a held message. The recipient-bound URL is minted only when the final recipient set is approved.

type ValidationError

type ValidationError struct {
	Message string
}

ValidationError indicates a caller error (invalid addresses, no visible recipients). Handlers should map this to HTTP 400.

func (*ValidationError) Error

func (e *ValidationError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL