Documentation
¶
Overview ¶
Package sender is the claim → render → SMTP loop of architecture 8.
One Sender is one replica. It polls the delivery queue per tenant and lane (ADR-0002), renders each delivery for its recipient (internal/render), applies the tracking and unsubscribe transforms of architecture 9.2, builds the MIME message of architecture 10, and hands it to a pooled SMTP connection. SMTP results are normalized into the five error classes of architecture 4.2 and committed in batches, with an immediate MarkSent right after 250 so that a crash before the batch commits costs a duplicate at most, never a lost send.
The package does not import the root sendplane package: the root package is what calls Run. The types the host injects (Hooks, OutboundMessage, SecretCipher, Metrics) come from the leaf package host instead, which the root re-exports as aliases, so Config is filled straight from Options with no adapter. See README.
Index ¶
- Constants
- Variables
- func FailureReason(shared bool, reason string) string
- func Reusable(f Failure) bool
- func SharedSenderReason() string
- func ValidateCustomHeader(name, value string) error
- type Config
- type Conn
- type Decision
- type DialFunc
- type Failure
- type Key
- type Limiter
- type Policy
- type Pool
- type PoolConfig
- type Sender
Constants ¶
const ( // HeaderSendplaneID correlates a DSN with a delivery even when the relay // rewrote the envelope sender. HeaderSendplaneID = "X-Sendplane-ID" // HeaderAttempt carries the attempt number. It makes a retry visible in a // relay's logs, and it is what chaossmtp keys its deterministic failures // on. HeaderAttempt = "X-Sendplane-Attempt" // HeaderProbe carries "{run_id}/{hmac}" on a lane=probe message. It is // what the loopback collector searches the probe mailbox by (IMAP SEARCH // HEADER), and the HMAC is what keeps a mail somebody else dropped in that // mailbox from producing a verdict (architecture 11.2, ADR-0012). HeaderProbe = "X-Sendplane-Probe" )
sendplane headers (architecture 10).
const ( MetricClaimed = "sendplane.sender.claimed" MetricProcessed = "sendplane.sender.processed" MetricRenderTime = "sendplane.sender.render.seconds" MetricSendTime = "sendplane.sender.smtp.seconds" MetricLimiterWait = "sendplane.sender.limiter.wait.seconds" MetricConnOpened = "sendplane.sender.conn.opened" MetricConnClosed = "sendplane.sender.conn.closed" MetricTransport = "sendplane.sender.transport.status" )
Metric names. They are constants so that a dashboard and a test refer to the same string.
The sink itself is host.Metrics (Count/Observe), which lives in the host package because Config.Metrics is filled straight from the root package's Options: a metrics interface declared here would force the root package to import internal/sender just for a type.
const ( EventDeliverySent = "delivery.sent" EventDeliveryFailed = "delivery.failed" )
The per-delivery event types the send path emits (architecture 12). They are written to the same store as the result they describe, right after Complete committed it, which is the outbox pattern the campaign and bounce events already use.
Both are off unless the tenant subscribes to them, and delivery.sent is off even in the default set: it is one outbox row, one dispatch and one HTTP POST per recipient, so a million-recipient campaign would produce a million of each (store.DefaultOffEventTypes).
const ( DefaultClaimBatch = 64 DefaultLeaseFor = 2 * time.Minute DefaultPollInterval = 200 * time.Millisecond DefaultCampaignRefresh = 3 * time.Second DefaultTenantConcurrency = 64 DefaultTenantCacheTTL = 5 * time.Second DefaultHeartbeatInterval = 10 * time.Second DefaultWorkerTTL = 30 * time.Second DefaultResultBatchSize = 100 DefaultResultFlushInterval = time.Second DefaultRenderTimeout = 10 * time.Second DefaultTransportFailThreshold = 3 DefaultTransportProbeInterval = time.Minute )
Default configuration values (architecture 8).
Variables ¶
var ( // ErrHeaderInjection is returned when an address, subject or header value // carries CR or LF (architecture 16). ErrHeaderInjection = errors.New("sender: header injection") // ErrHeaderNotAllowed is returned for a custom header outside the // allowlist. ErrHeaderNotAllowed = errors.New("sender: header not allowed") // ErrNoBody is returned when neither an HTML nor a text part rendered. ErrNoBody = errors.New("sender: message has no body") )
Message errors.
var ErrPoolClosed = errors.New("sender: connection pool closed")
ErrPoolClosed is returned by Get after Close.
Functions ¶
func FailureReason ¶
FailureReason is what a tenant may be told about why a send could not go out: the real reason for its own resources, the generic one for a shared resource.
func Reusable ¶
Reusable reports whether a connection survives a failure: a normal SMTP rejection leaves the session usable, a 421 or a network error does not.
func SharedSenderReason ¶
func SharedSenderReason() string
SharedSenderReason is the generic reason string, exported so that the HTTP layer says exactly the same thing on campaign start.
func ValidateCustomHeader ¶
ValidateCustomHeader is the whitelist check of architecture 16, applied to every caller-supplied header: a Hooks.BeforeSend addition, and the `headers` of POST /messages that store.Delivery.Headers carries.
It is exported so that the HTTP layer can refuse a disallowed name at 422 instead of storing a header that would fail the delivery at send time. A header that passes here is one the sender will actually put on the wire.
Types ¶
type Config ¶
type Config struct {
// WorkerID identifies this replica. It is the lease owner on claimed
// deliveries and the ID of its Workers heartbeat row, so it must be stable
// for the life of the process and unique in the cluster.
WorkerID string
// Lanes is the worker pool size per lane. Transactional is always served
// before bulk. Default: 8 transactional, 32 bulk.
Lanes map[store.Lane]int
// ClaimBatch is the maximum number of deliveries claimed in one call.
ClaimBatch int
// LeaseFor is how long a claim holds a delivery (architecture 8.1).
LeaseFor time.Duration
// PollInterval is how long the loop sleeps after a pass that claimed
// nothing.
PollInterval time.Duration
// CampaignRefresh is the TTL of the running-campaign set.
CampaignRefresh time.Duration
// TenantConcurrency caps the deliveries one tenant may occupy at once, so
// one big campaign cannot starve the others.
TenantConcurrency int
// TenantCacheTTL is the TTL of cached tenant configuration rows.
TenantCacheTTL time.Duration
// HeartbeatInterval is how often the Workers row is refreshed, WorkerTTL
// how far back ListActive looks. The active replica count divides the
// cluster-wide transport rate (architecture 8.2).
HeartbeatInterval time.Duration
WorkerTTL time.Duration
// DefaultRatePerSecond applies to transports that configure no rate. Zero
// means unlimited.
DefaultRatePerSecond float64
// ResultBatchSize and ResultFlushInterval drive the batched Complete.
ResultBatchSize int
ResultFlushInterval time.Duration
// Connection pool.
MaxMsgsPerConn int
ConnIdleTimeout time.Duration
DialTimeout time.Duration
SendTimeout time.Duration
EHLOName string
TLSConfig *tls.Config
Dialer DialFunc
// RenderTimeout bounds one recipient's Liquid render.
RenderTimeout time.Duration
// TransportFailThreshold is how many consecutive auth/TLS/connect failures
// mark a transport unhealthy; TransportProbeInterval how often an unhealthy
// one is probed (architecture 8.3).
TransportFailThreshold int
TransportProbeInterval time.Duration
// RateLimitCap and AuthRetryAfter are the policy knobs of policy.go.
RateLimitCap time.Duration
AuthRetryAfter time.Duration
Hooks host.Hooks
Secrets host.SecretCipher
Renderer *render.Renderer
// Platform resolves the shared senders of the operator's platform catalog:
// their templated From addresses, the uses they are allowed for, and the
// per-tenant rate share of the transport behind them (ADR-0017). Nil in a
// deployment with no platform resources, which is every single-tenant one.
Platform *platform.Resolver
Metrics host.Metrics
Logger *slog.Logger
Clock func() time.Time
// Rand is the jitter source; nil uses math/rand/v2.
Rand func() float64
}
Config configures one sender replica.
type Conn ¶
type Conn struct {
// contains filtered or unexported fields
}
Conn is a checked-out SMTP connection.
type Decision ¶
type Decision struct {
Status store.DeliveryStatus
NextAttemptAt time.Time
// IncrementAttempt is true for transient and rate_limited only. Transport
// faults (auth, TLS) are recorded but must not exhaust a delivery.
IncrementAttempt bool
// RecordAttempt is false when nothing was attempted over SMTP at all
// (suppressed, skipped), so no DeliveryAttempt row is written.
RecordAttempt bool
}
Decision is what happens to a delivery after one attempt: the status to commit, when it becomes eligible again, and whether this attempt consumes one of the retries (architecture 4.1, ADR-0003).
type Failure ¶
type Failure struct {
Class store.ErrorClass
// Code is the SMTP reply code, or 0 for a failure that never got one
// (connection refused, timeout, TLS).
Code int
// Enhanced is the RFC 3463 status ("4.7.0"), when the reply carried one.
Enhanced string
// Message is the reply text, or the Go error text.
Message string
// Rule names the classification rule that matched, for logs and tests.
Rule string
Err error
}
Failure is a normalized transport failure: the SMTP reply if there was one, and the error class that decides what happens next (architecture 4.2).
type Key ¶
Key is one bucket: a name and the cluster-wide rate configured for it. A zero or negative rate means unlimited and costs nothing.
type Limiter ¶
type Limiter struct {
// Workers reports the number of active sender replicas. It must return at
// least 1; a nil func means "this replica is alone".
Workers func() int
// Now is the clock (tests inject one).
Now func() time.Time
// Sleep waits; tests replace it to avoid real time.
Sleep func(ctx context.Context, d time.Duration) error
// contains filtered or unexported fields
}
Limiter is the token bucket set of architecture 8.2.
A transport's configured RatePerSecond is a cluster-wide target. Each replica applies its own share of it - the target divided by the number of sender replicas that have a live heartbeat - so the cluster converges on the target without a central lock, within one heartbeat period of a replica count change.
On a rate_limited reply the bucket halves (Penalize) and then recovers by 10% per minute up to its share again: additive-increase / multiplicative-decrease, the same shape TCP uses.
func NewLimiter ¶
NewLimiter returns a limiter. workers may be nil.
func (*Limiter) Penalize ¶
Penalize halves the rate of every named bucket after a rate_limited reply.
type Policy ¶
type Policy struct {
Retry store.RetryPolicy
// RateLimitCap shortens the backoff for rate_limited: architecture 4.2
// asks for "a short retry plus a transport-level slowdown", and the
// slowdown (ratelimit.go) is what actually spaces the traffic out, so the
// delivery itself should come back soon.
RateLimitCap time.Duration
// AuthRetryAfter is how long an auth-class failure parks the delivery. It
// stays queued and does not consume an attempt, so without a delay the
// same delivery would be re-claimed in a tight loop while the transport is
// being probed.
AuthRetryAfter time.Duration
// Jitter is the fraction the backoff is randomized by, ±Jitter.
Jitter float64
// Rand returns a value in [0,1). nil uses an internal source.
Rand func() float64
}
Policy turns an error class into a Decision. It holds the tenant retry policy plus the two knobs that are not tenant-configurable.
func DefaultPolicy ¶
func DefaultPolicy(retry store.RetryPolicy) Policy
DefaultPolicy fills in the non-tenant knobs.
type Pool ¶
type Pool struct {
// contains filtered or unexported fields
}
Pool holds one connection group per transport.
func (*Pool) Close ¶
func (p *Pool) Close()
Close drops every idle connection. Connections still checked out are closed by their Put.
func (*Pool) CloseTransport ¶
CloseTransport drops every idle connection of a transport, used when it is marked unhealthy.
type PoolConfig ¶
type PoolConfig struct {
// MaxMsgsPerConn reconnects after this many messages on one connection.
// Long-lived connections accumulate per-connection limits on the far side
// (architecture 8.1).
MaxMsgsPerConn int
// IdleTimeout closes a connection that has been idle this long.
IdleTimeout time.Duration
// DialTimeout bounds connect + greeting + EHLO + AUTH.
DialTimeout time.Duration
// SendTimeout bounds one MAIL/RCPT/DATA transaction.
SendTimeout time.Duration
// EHLOName is the name announced in EHLO.
EHLOName string
// TLSConfig is the base config for STARTTLS and implicit TLS; ServerName
// is filled in from the transport host.
TLSConfig *tls.Config
// Dialer defaults to a net.Dialer.
Dialer DialFunc
Now func() time.Time
// Metrics is never nil after withDefaults.
Metrics host.Metrics
}
PoolConfig configures Pool.
type Sender ¶
type Sender struct {
// contains filtered or unexported fields
}
Sender is one replica of the send loop.