outbound

package
v1.9.1 Latest Latest
Warning

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

Go to latest
Published: Sep 5, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

Documentation

Index

Constants

View Source
const MaxAttachmentFilenameBytes = 1024

MaxAttachmentFilenameBytes bounds user-controlled MIME parameter expansion. RFC 2231 percent-encoding can triple the filename's byte length; without a separate bound, a small attachment with a multi-megabyte filename can pass decoded-content limits and force disproportionately large allocations during composition.

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.

View Source
const ProviderAttemptHeader = "X-E2A-Provider-Attempt"

ProviderAttemptHeader carries the random attempt correlation id SES echoes back in delivery feedback. It is the fallback lookup when the worker died between SES accepting the message and the provider id being stored.

View Source
const SESConfigurationSetHeader = "X-SES-CONFIGURATION-SET"

SESConfigurationSetHeader selects the SES configuration set (delivery feedback destination) for a submission.

View Source
const SESTenantHeader = "X-SES-TENANT"

SESTenantHeader names the SES tenant a submission is attributed to.

Variables

View Source
var (
	// ErrAuthorizationRequired means SubmitOnce was called without a token.
	// There is no tokenless path to the provider by construction; a caller
	// hitting this has bypassed the gate.
	ErrAuthorizationRequired = errors.New("outbound: provider submission requires an authorization")
	// ErrTenantNameMissing means the token demands a tenant header but carries
	// no tenant name. The gate refuses to mint such a token; the adapter checks
	// again because the header is the provider-side isolation boundary and
	// must never be emitted empty.
	ErrTenantNameMissing = errors.New("outbound: authorization requires a tenant header but names no tenant")
	// ErrProviderHeaderValue means a provider-owned header value carries a
	// line break. The values come from the gate, so this is a defect, not
	// input — and a defect here is a header injection, so it fails closed
	// rather than being sanitized silently.
	ErrProviderHeaderValue = errors.New("outbound: provider header value contains a line break")
	// ErrMalformedHeaderSection means the composed MIME's header section holds
	// a bare carriage return. Receivers disagree on whether a lone CR ends a
	// line, so a header hidden behind one might survive stripping here and
	// still be honoured by the provider. The composer never emits one — every
	// header value is sanitized — so this only fires on a compose defect, and
	// it fails the send rather than guess.
	ErrMalformedHeaderSection = errors.New("outbound: message header section contains a bare carriage return")
)

Sentinel errors for the provider seam. Every one of them is returned before any network I/O and before the token is redeemed.

View Source
var ErrProviderAcceptanceUnknown = errors.New("outbound smtp: message body delivered, provider acceptance unknown")

ErrProviderAcceptanceUnknown marks a failure that happened AFTER the whole message body was handed to the provider: the terminating dot was written and the 250 never arrived. The provider may have accepted the message. No classifier can call this permanent or transient, and a caller that retries it as if nothing was sent will deliver the message twice. The sending protection seam leaves such an attempt unsettled; delivery feedback carrying the attempt header is the only authoritative answer.

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 BuildReplyQuoteAttribution added in v1.7.7

func BuildReplyQuoteAttribution(ctx ForwardContext) string

BuildReplyQuoteAttribution renders the attribution line above the quoted parent. Degrades gracefully when the parent's headers failed to parse.

func BuildReplyQuoteBody added in v1.7.7

func BuildReplyQuoteBody(replyText string, ctx ForwardContext) string

BuildReplyQuoteBody composes the text/plain body of a quoted reply: the caller's reply text, a blank line, the attribution line, then the parent text with every line ">"-prefixed. A parent that already carries ">" quoting nests naturally (">>"), matching mail-client behavior. A parent with no quotable body at all drops the quote block entirely — the reply goes out exactly as the caller wrote it.

func BuildReplyQuoteHTMLBody added in v1.7.7

func BuildReplyQuoteHTMLBody(replyHTML string, ctx ForwardContext) string

BuildReplyQuoteHTMLBody composes the text/html body of a quoted reply. The caller's HTML is emitted as-is (caller-controlled markup, same contract as forward); the parent HTML is wrapped in a Gmail-style blockquote under the attribution line. Falls back to the escaped parent text in <pre> when the parent has no HTML part; drops the quote block when the parent has neither.

func 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 SignWithDKIM added in v1.7.0

func SignWithDKIM(lookup DKIMKeyLookup, message []byte, domain string) ([]byte, bool)

SignWithDKIM is the shared sign-then-proceed step: look up a DKIM keypair for the From-header domain and return a signed copy of the message. Returns (nil, false) when lookup is nil, no key is stored for the domain, or signing fails — callers proceed with the unsigned message rather than failing the send (fail-open: a signing problem must never break mail, and the zero-config self-host path has no key at all).

Exported for senders that compose outside *Sender (webhooknotify): the SMTP relay itself never DKIM-signs, and an upstream like SES only signs identities it manages — a BYODKIM custom domain is signed here or not at all.

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.

func ValidateAttachmentFilenames added in v1.5.0

func ValidateAttachmentFilenames(atts []Attachment) error

ValidateAttachmentFilenames applies the shared composition-time filename bound before any RFC 2231 encoding or MIME allocation takes place.

func ValidateMailboxAddress added in v1.5.0

func ValidateMailboxAddress(address string) error

ValidateMailboxAddress enforces SMTP's octet limits on a parsed addr-spec. Unicode code-point limits alone are insufficient: a syntactically valid SMTPUTF8 local part can occupy four bytes per rune and become an indivisible header token that exceeds both the mailbox and header-line limits.

Types

type Attachment

type Attachment struct {
	Filename    string `` /* 138-byte string literal not displayed */
	ContentType string `json:"content_type" example:"application/pdf"`
	Data        string `` // base64-encoded
	/* 195-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 Envelope added in v1.9.0

type Envelope struct {
	// From is the SMTP MAIL FROM address.
	From string
	// Recipients is the exact final envelope: one entry per distinct mailbox,
	// and exactly the set the authorization was minted for. Order is free.
	Recipients []string
	// Message is the composed MIME. Provider-owned headers in it are stripped.
	Message []byte
}

Envelope is what a caller hands the provider seam: the SMTP envelope and the composed wire bytes.

There is deliberately no message id here. The stable X-E2A-Message-ID marker that delivery feedback keys on is derived from the token — a customer message's operation IS its message id — so a caller cannot stamp one message's id on another's send and misroute its bounces.

The sender is the one envelope field the token does not bind: the authorization carries recipients and tenant, not MAIL FROM. SES enforces identity ownership of the sender domain on its side, and the caller here is the trusted worker that composed the message, so the seam only insists the sender is present. Binding it would need the gate to learn the composed sender at acceptance; that is a gate change, not an adapter one.

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 ProviderResult added in v1.9.0

type ProviderResult struct {
	// ProviderMessageID is the id SES assigned on acceptance.
	ProviderMessageID string
	// Attempt is the durable attempt that was redeemed for this call.
	Attempt sendingpolicy.AttemptRef
	// SettlementErr is set when SES accepted the message but the local
	// settlement did not commit. The send HAPPENED; the caller must retry
	// SettleProvider with the same attempt and provider id, and must never
	// resubmit. Delivery feedback carrying the attempt header is the fallback
	// if it never does.
	//
	// One value is not a retry: errors.Is(SettlementErr,
	// sendingpolicy.ErrProviderMessageIDConflict) means this attempt was
	// already settled with a DIFFERENT provider id — two physical sends for one
	// charge. Retrying settlement cannot fix that; it must be surfaced as an
	// invariant violation, not absorbed as a transient.
	SettlementErr error
}

ProviderResult reports one accepted provider submission.

type ProviderSubmitter added in v1.9.0

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

ProviderSubmitter is the token-requiring adapter over the SMTP relay.

func NewProviderSubmitter added in v1.9.0

func NewProviderSubmitter(relay *SMTPRelay, gate sendingpolicy.Gate) *ProviderSubmitter

NewProviderSubmitter binds the relay to the gate whose tokens it honors.

func (*ProviderSubmitter) SESConfigurationSet added in v1.9.0

func (s *ProviderSubmitter) SESConfigurationSet() string

SESConfigurationSet reports the configured configuration set, for wiring tests that must prove delivery feedback stayed switched on.

func (*ProviderSubmitter) SetSESConfigurationSet added in v1.9.0

func (s *ProviderSubmitter) SetSESConfigurationSet(name string)

SetSESConfigurationSet names the configuration set every submission is tagged with. Empty means no header (dev/self-host without SES).

func (*ProviderSubmitter) SubmitOnce added in v1.9.0

SubmitOnce makes exactly one provider call for one authorized attempt.

The sequence is fixed and every early exit is I/O-free: prove the envelope is the authorized one, derive the provider headers from the token, rewrite the wire bytes, redeem the token, THEN dial. A relay-level retry is not offered here on purpose — each physical submission exposes SES once and must be charged once, so a retry is a new attempt with a new token, obtained by the caller through the gate.

Outcomes: a definite permanent rejection is settled as such and returned; an acceptance is settled with the provider id and returned as a result. Anything ambiguous (4xx, connection loss, cancellation) is returned unsettled, because a message that might have been delivered must not release anything.

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

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"`
	// AppendOutboundFooter, when true, makes compose append the
	// operator-configured outbound footer (Sender.SetOutboundFooter) to the
	// message body, above any managed-unsubscribe line. Resolved server-side
	// by the agent layer (per-account entitlement + account class + config
	// master switch) — never caller-supplied, never part of the wire API
	// contract.
	AppendOutboundFooter bool `json:"-"`
	// ScheduledAt, when non-nil, defers this send to a future instant: the
	// message is accepted + queued immediately (delivery_status='accepted'), but
	// its River outbound_send job is held until this time. Nil means send now.
	// The API edge validates it (future + within the max horizon) and normalizes
	// a nil/past value to nil before this reaches DeliverOutbound.
	ScheduledAt *time.Time `json:"scheduled_at,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) SetOutboundFooter added in v1.6.0

func (s *Sender) SetOutboundFooter(text, html string)

SetOutboundFooter configures the operator-defined footer content appended when a SendRequest carries AppendOutboundFooter. Optional-setter pattern (cf. SetSendingStatusLookup) so existing call sites and tests are unaffected; empty text+html leaves the append a no-op.

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.

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