Documentation
¶
Index ¶
- Constants
- func BuildForwardBody(comment string, ctx ForwardContext) string
- func BuildForwardHTMLBody(commentHTML string, ctx ForwardContext) string
- func BuildForwardSubject(orig string) string
- func BuildReferencesChain(rawMessage []byte, parentMsgID string) []string
- func ComposeMessage(from string, to []string, cc []string, ...) ([]byte, error)
- func ComposeMessageWithAttachments(from string, to []string, cc []string, ...) ([]byte, error)
- func ComposeMultipartMessage(from string, to []string, cc []string, ...) ([]byte, error)
- func ComposedSize(subject, text, html string, atts []Attachment) int
- func DecodeAttachmentData(data string) ([]byte, error)
- func IsComposedSizeError(err error) bool
- func IsConnectionError(err error) bool
- func IsPermanentSMTPError(err error) bool
- func IsTransientSMTPError(err error) bool
- func IsValidationError(err error) bool
- func NormalizeRecipients(agent *identity.AgentIdentity, fromDomain string, req SendRequest) ([]string, []string, []string, []string, error)
- func PlatformEnvelopeFrom(fromDomain string) string
- func SuppressionRemediation(agentID string) string
- type Attachment
- type ComposeResult
- type ComposedSizeError
- type DKIMKeyLookup
- type ForwardContext
- type ReplyRecipients
- type SMTPRelay
- func (r *SMTPRelay) Configured() bool
- func (r *SMTPRelay) Send(from string, recipients []string, message []byte) (string, error)
- func (r *SMTPRelay) SendOnce(envelopeFrom string, recipients []string, message []byte) (string, error)
- func (r *SMTPRelay) SendWithContext(ctx context.Context, from string, recipients []string, message []byte) (string, error)
- func (r *SMTPRelay) SendWithEnvelope(envelopeFrom string, recipients []string, message []byte) (string, error)
- func (r *SMTPRelay) SendWithEnvelopeContext(ctx context.Context, envelopeFrom string, recipients []string, message []byte) (string, error)
- type SendRequest
- type SendResult
- type Sender
- func (s *Sender) ComposeForAccept(agent *identity.AgentIdentity, req SendRequest) (*ComposeResult, error)
- func (s *Sender) ComposePlatformForAccept(req SendRequest) (*ComposeResult, error)
- func (s *Sender) Send(agent *identity.AgentIdentity, req SendRequest) (*SendResult, error)
- func (s *Sender) SendOnce(agent *identity.AgentIdentity, req SendRequest) (*SendResult, error)
- func (s *Sender) SetSESConfigurationSet(name string)
- func (s *Sender) SetSendingStatusLookup(l SendingStatusLookup)
- func (s *Sender) SubmitOnce(messageID, envelopeFrom string, recipients []string, sentBody []byte) (string, error)
- type SendingStatusLookup
- type UnsubscribeOptions
- type ValidationError
Constants ¶
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.
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 ¶
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 ¶
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 ¶
DecodeAttachmentData decodes a base64-encoded attachment data string.
func IsComposedSizeError ¶
func IsConnectionError ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 (*SMTPRelay) Send ¶
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.
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 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 ¶
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 ¶
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