Documentation
¶
Overview ¶
Package mail queues and delivers outbound mail.
Nothing sends on the request path. A consumer calls Enqueue, which renders the message and writes one row; the scheduler drains that table on its own clock. The table is the reason for the split (decision D23): an invitation that vanished because a deploy landed mid-retry would be invisible on both ends — nobody receives it, and nobody knows one was attempted.
The whole package is optional. An instance with no SMTP_HOST never builds a Service and every consumer holds nil, which is the claim that keeps the mailer optional rather than quietly required.
"And the outbox stays empty" used to be part of that sentence, and it was true only of an instance that never had SMTP_HOST — not of one that had it and had it cleared, which keeps every row enqueued before the change. Those rows are undrainable, because the drain is gated on the mailer, and they were unpurgeable, because PurgeFinishedMail takes only rows that are not pending (F52). The scheduler now abandons them past the retention window on the no-mailer path, so the transition ends somewhere instead of nowhere.
What a queued message is worth to somebody reading the database ¶
A rendered body is a credential while the message it renders carries one, and two of the four templates do: an invitation and an address verification each contain a single-use token whose only other copy in the schema is a SHA-256 hash. So a row's body is blanked in the same statement that marks it sent or failed (finding F32), and the database refuses to hold a finished row that still has one. Enqueue to delivery is the window, not the retention window, and nothing here shortens it further: a message that has not been delivered has to keep the message it is going to send.
Index ¶
Constants ¶
const ( // MaxAttempts is how many deliveries one message gets before it is marked // failed. Counted at claim time, so a process that dies mid-send spends an // attempt — otherwise a crash loop would retry the same message forever. MaxAttempts = 5 // BackoffBase is the first delay, doubling per attempt up to BackoffMax. // With these values the five attempts span roughly half an hour: 1m, 2m, // 4m, 8m, 16m. BackoffBase = time.Minute BackoffMax = 30 * time.Minute // DrainBatch bounds one drain. Small, because each row is a network round // trip to somebody else's server and the scheduler runs every half minute // anyway: a backlog drains over several runs instead of holding the job for // minutes. DrainBatch = 20 // SendConcurrency is how many of a claimed batch are handed to the relay at // once, and it is DrainBatch because that is what makes the sentence above // true. // // There was no such constant before and the value it replaces was one, which // is finding F133 — the shape M42's reopening had already fixed one package // over (D95), on the same scheduler goroutine, one line earlier in the same // select case. A batch sent one message after another costs DrainBatch times // SMTP_TIMEOUT: twenty times ten seconds at the shipped defaults, against a // relay that accepts connections and then says nothing, which is what a // firewalled SMTP host looks like from here. That time is spent inline on the // goroutine that reads every scheduled job's ticker, and a Go ticker holds one // tick, so the rest are dropped rather than queued. The cost never landed on // mail: it landed on webhook delivery, on automation's advertised clock, on // domain re-verification and on the rollups. // // Equal to DrainBatch rather than smaller, for D95's reason. Any value below // it puts the batch size back into the wall-clock cost — a limit of eight is // three waves, thirty seconds at the default timeout, and back inside the tick // it has to fit under. What keeps the number small is the claim itself: a // drain never holds more than DrainBatch rows, so it can never open more than // that at once. // // **The difference from webhooks is where the connections go.** A webhook // batch is spread over as many receivers as it has rows; this one opens up to // twenty sessions to the single relay the operator named. A relay that caps // concurrent connections below twenty refuses the extra ones, and a refused // message is a spent attempt that retries with backoff — the path a refusal // already takes, and no message is lost by it. What it costs is the tail: a // backlog held continuously above the cap for five attempts abandons the // overflow where the sequential version would have crawled. That trade is // taken deliberately, because the stall it removes is instance-wide and // needs no misconfiguration, while this needs a relay that is both // connection-capped and continuously saturated. SendConcurrency = DrainBatch // FinishedRetentionDays is how long a sent or failed row is kept. // // The outbox is a record of what was attempted, not an archive. Without a // window it would be the one table in this schema that grows forever with // nothing watching it, which is the shape D5 and M21 exist to stop // repeating. // // It bounds the record and not a secret. A row that reaches this window lost // its body when it finished, so lowering the number would shorten no // credential's exposure — which is why F32 was fixed by scrubbing rather // than by tightening this. FinishedRetentionDays = 30 )
Retry policy. Bounded, and bounded on purpose: a relay that has refused a message five times over half an hour is not going to accept it on the sixth, and a queue that retries forever is one where a single poisoned row is attempted every tick until somebody notices.
const ( TLSStartTLS = "starttls" TLSImplicit = "tls" TLSNone = "none" )
TLS modes, mirroring the configuration values. Named here as well so this package does not import internal/config.
Variables ¶
var ErrHeaderInjection = errors.New("mail: header value contains a line break")
ErrHeaderInjection is returned when a value that becomes a header carries a line break.
Functions ¶
func Backoff ¶
Backoff is the delay before attempt n+1, given that n attempts have been made. Doubling from BackoffBase, capped at BackoffMax.
**BackoffMax is unreachable at the shipped MaxAttempts**, and that is worth stating where somebody reads the constants rather than leaving it to be derived. Drain refuses a row at `attempts >= MaxAttempts` before asking for a delay, so with five attempts the waits are 1m, 2m, 4m and 8m — fifteen minutes end to end. Raising MaxAttempts is what makes the 30m cap do anything; a document that read the cap off this function and called the span 1m to 16m was wrong for exactly that reason (F45).
No jitter: this is one leader draining one queue on a fixed tick, not N clients stampeding a service, so there is nothing to spread out.
Types ¶
type Config ¶
Config is what a Service needs. Its own struct rather than config.Config, matching every other service in this tree: the package that does the work does not read the environment.
type Enqueuer ¶
type Enqueuer interface {
Enqueue(ctx context.Context, to, kind string, data map[string]string) error
}
Enqueuer is the writing half, as a consumer sees it.
Consumers hold this rather than *Service so that "no mailer configured" is a nil interface rather than a flag every consumer has to remember to check — and so a consumer's tests need neither a database nor a relay.
type Renderer ¶
type Renderer interface {
RenderMail(name string, data map[string]string) (subject, body string, err error)
}
Renderer turns a template name and its data into a message.
An interface so this package never imports the one that owns the words. internal/ui satisfies it; a test satisfies it in four lines.
type SMTPOptions ¶
type SMTPOptions struct {
// Host and Port are dialled; Host is also the TLS server name and the realm
// PLAIN authenticates against, so it must be the name on the certificate
// rather than an address.
Host string
Port int
Username string
Password string
// From is the envelope sender and the From header. Either a bare address or
// a display-name form; config validation has already parsed it.
From string
TLS string
Timeout time.Duration
}
SMTPOptions is one relay, as this package needs it.
type SMTPSender ¶
type SMTPSender struct {
// contains filtered or unexported fields
}
SMTPSender delivers over SMTP, with the smallest surface that can honestly claim to work.
What is supported: STARTTLS on submission, implicit TLS, or an unencrypted connection to a relay that needs no credentials; PLAIN authentication, over an encrypted connection only. What is not: LOGIN, CRAM-MD5, XOAUTH2, client certificates, and any relay that requires them. That list is in docs/configuration.md too, because a mailer that implies universal compatibility and fails at the first send is worse than one that says what it does.
func NewSMTPSender ¶
func NewSMTPSender(opts SMTPOptions) (*SMTPSender, error)
func (*SMTPSender) Addr ¶
func (s *SMTPSender) Addr() string
Addr is the relay this sender talks to, for log lines.
func (*SMTPSender) Send ¶
func (s *SMTPSender) Send(ctx context.Context, to, subject, body string) error
Send delivers one message.
func (*SMTPSender) Verify ¶
func (s *SMTPSender) Verify(ctx context.Context) error
Verify opens a connection, greets, and hangs up without sending anything.
Called at boot so a mistyped host, a wrong port or a rejected password is reported once, at startup, by the process that could have told you — instead of surfacing weeks later as an invitation that never arrived. It is a warning and not a fatal error: the relay being down is not a reason for a link shortener to stop serving redirects, and anything queued in the meantime is retried from the outbox.
**Called off the startup path**, in a goroutine of its own — never inline before the HTTP listener binds. This dials, so an unreachable relay costs the caller the whole of Timeout, and a caller that is the boot sequence spends that with nothing serving (F173, D166).
type Sender ¶
Sender delivers one message. The seam the integration tests substitute at, because standing up a real relay to prove the outbox drains would be testing somebody else's SMTP server.
**Called from SendConcurrency goroutines at once**, because Drain hands a claimed batch to the relay together. SMTPSender qualifies by holding nothing across a call — its options are read-only and each Send dials its own connection — and anything substituted here has to do the same.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the outbox: enqueue on one side, drain on the other.
func (*Service) Drain ¶
Drain sends everything due, and is what the scheduler calls.
One row's failure never stops the batch: errors are collected and the remaining messages are still attempted, because a single unreachable recipient must not hold up everyone else's mail.
**The batch goes to the relay together, and Drain does not return until all of it has.** Both halves are load-bearing, and the second is why this is written with a WaitGroup rather than fired and forgotten. The caller is `withLeadership` in cmd/linkctrl/jobs.go — the same caller the webhook drain has, with the same constraint, which is what made D95's shape correct here rather than merely similar. It holds D77's advisory lock on a pooled connection it releases the moment the function it was given returns, so a goroutine that outlived this call would send *without* the lock, and `pg_try_advisory_lock` is session-scoped, so a goroutine of its own would not even have one. Waiting here means the lock covers every send exactly as it did when they were sequential, and neither D77 nor the skip-locked claim beneath it moves.
func (*Service) Enqueue ¶
Enqueue renders a message and queues it.
The message is rendered here, not at send time, and stored rendered. A template change must not silently rewrite a mail somebody is already waiting for, and a row has to stay readable after the code that produced it is gone.