Documentation
¶
Overview ¶
Package mailer is the outbound-email abstraction: the rest of the app talks to a Mailer interface and never reaches for net/smtp itself. Two concrete implementations live here:
- SMTPMailer dials a real SMTP relay (e.g. MailHog in dev, SES/SendGrid SMTP in production) and is selected when Config.Host is set.
- LogMailer writes the rendered message to the structured log; it is the dev/no-SMTP fallback so the app still boots when SMTP isn't configured.
Adding a new provider later (SES native API, SendGrid HTTP) is a matter of implementing the Mailer interface — no caller code needs to change.
Decorator pattern ¶
Cross-cutting concerns around Send (retries, structured logging, metrics) are NOT baked into the SMTP/Log mailers. Instead this package ships three composable decorators — RetryingMailer, LoggingMailer, MetricsMailer — each of which embeds an inner Mailer, adds one behaviour, and exposes the same Send(ctx, msg) signature. They are wired together in main.go in the order that matches the desired behaviour: the innermost decorator runs closest to the wrapped implementation, the outermost runs first/last. The pattern is Go's idiomatic "embed an interface, add behaviour, return it" — it keeps every concern in one file, makes each one independently testable, and lets new concerns (rate limiting, circuit breaker, sampling) drop in without touching the SMTP code.
See internal/mailer/Readme.md for the recommended composition order and a checklist for adding a new decorator.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrEmptyMessage = errors.New("mailer: message has no body")
ErrEmptyMessage is returned by Send when the supplied Message has no body at all. We refuse to dispatch a blank email rather than send something the recipient will ignore.
Functions ¶
This section is empty.
Types ¶
type Config ¶
Config drives New(). When Host is empty we return a LogMailer instead of trying to dial — this is the local-development default so the app still boots without MailHog running. Username/Password are optional; when Username is "" we pass nil auth (MailHog accepts unauthenticated SMTP).
type LogMailer ¶
type LogMailer struct {
Logger logrus.FieldLogger
From string
}
LogMailer writes a structured log line for every send instead of delivering. Useful for local development and for any deployment that has not (yet) wired up an SMTP relay.
func (*LogMailer) Send ¶
Send writes the message metadata to the structured logger at info level. The body itself is intentionally NOT logged in full (passwords / reset tokens can show up inside it); a truncated preview is enough for a developer to confirm the right email was triggered.
Metrics: the emails_sent_total counter is NOT incremented here; that responsibility lives in the MetricsMailer decorator (see metrics.go).
type LoggingMailer ¶
type LoggingMailer struct {
// contains filtered or unexported fields
}
LoggingMailer wraps an inner Mailer with structured-log breadcrumbs: one "mailer.send" entry on entry, one "mailer.send failed" entry on error. It deliberately does NOT log the body — payloads can contain password-reset tokens or order PII that should not land in the logs twice (the leaf LogMailer already redacts to a 200-char preview).
LoggingMailer is the outermost decorator in the standard composition: it sees the FINAL outcome of the retry loop, so a transient failure followed by a successful retry produces ONE info entry, not three.
func NewLogging ¶
func NewLogging(inner Mailer, logger logrus.FieldLogger) *LoggingMailer
NewLogging constructs a LoggingMailer. A nil logger is permitted (the decorator becomes a passthrough) so unit tests that don't care about the log output can skip the fake-logger boilerplate.
type Mailer ¶
Mailer is the abstraction every caller in the codebase depends on. The concrete implementation is chosen at composition-root time via New(); call sites never type-assert to a specific implementation.
type Message ¶
type Message struct {
To string
From string
Subject string
HTMLBody string
TextBody string
// Kind tags the email template ("order_confirmation",
// "password_reset", ...). It is used only by the observability layer
// to label the gocommerce_emails_sent_total counter — an empty Kind
// is recorded as "unknown" and is otherwise harmless to the MIME
// payload.
Kind MessageKind
}
Message is one outbound email. From is optional: when blank, the Mailer fills in its configured default sender. HTMLBody and TextBody are both optional but at least one MUST be set; when both are populated the message is built as multipart/alternative so clients pick the variant they render best.
type MessageKind ¶
type MessageKind = string
MessageKind describes what kind of email a Message is. It is propagated as a metric label (kind="order_confirmation", kind="password_reset", ...) so dashboards can group sent/failed counts by email template without leaking per-recipient cardinality. An empty Kind is recorded as "unknown".
const ( KindUnknown MessageKind = "unknown" KindOrderConfirmation MessageKind = "order_confirmation" KindOrderShipped MessageKind = "order_shipped" KindPasswordReset MessageKind = "password_reset" )
type MetricsMailer ¶
type MetricsMailer struct {
// contains filtered or unexported fields
}
MetricsMailer wraps an inner Mailer and increments gocommerce_emails_sent_total{kind, outcome} EXACTLY ONCE per Message — regardless of how many retries the inner stack performed. That is why MetricsMailer must sit OUTSIDE RetryingMailer in the composition: the counter records the final outcome of an end-to-end Send, not per attempt. (See cmd/web/main.go for the wiring + the inline rationale.)
The leaf SMTPMailer / LogMailer used to increment this counter themselves; that responsibility was moved here so retries do not double-count and so the metric is owned by a single, easily-disabled decorator.
func NewMetrics ¶
func NewMetrics(inner Mailer) *MetricsMailer
NewMetrics constructs a MetricsMailer that writes to the application emails_sent_total counter via observability.EmailsSentInc. A nil inner mailer is a programming error; we don't guard against it because composition happens once at startup and a nil inner would crash on the very first publish.
type RetryingMailer ¶
type RetryingMailer struct {
// contains filtered or unexported fields
}
RetryingMailer wraps an inner Mailer with bounded exponential-backoff retries. It is meant for transient SMTP failures (temporary DNS hiccups, brief MTA outages, "421 try again later") where a second attempt a few hundred milliseconds later usually succeeds.
When NOT to use this ¶
Retries are only safe when the wrapped operation is effectively idempotent from the user's perspective. SMTP itself is NOT strictly idempotent — the relay may have accepted the message and then dropped the connection before acknowledging it, in which case a retry will dispatch a second copy. For gocommerce that cost is low: a duplicate "order confirmation" email is embarrassing, not destructive.
Do NOT layer RetryingMailer in front of a sender that triggers paid side-effects (SMS billed per message, push-notification rate quotas, transactional webhooks) without first making the inner operation truly idempotent at the provider level (idempotency key, dedupe window).
func NewRetrying ¶
func NewRetrying(inner Mailer, attempts int, backoff time.Duration, clock func() time.Time) *RetryingMailer
NewRetrying constructs a RetryingMailer. attempts is the total number of Send invocations (including the first); values below 1 are clamped to 1 so a misconfigured caller still issues a single attempt rather than silently dropping the send. backoff is the base delay; the k-th retry sleeps backoff * 2^(k-1) (200ms, 400ms, 800ms, ...). A nil clock falls back to time.Now.
func (*RetryingMailer) Send ¶
func (r *RetryingMailer) Send(ctx context.Context, msg Message) error
Send calls inner.Send up to r.attempts times, sleeping backoff * 2^(i-1) between failures. It returns nil on the first success; on permanent failure it returns the last observed error wrapped with the attempt count so callers can grep logs for "after N attempts".
Context cancellation short-circuits the back-off: a cancelled ctx ends the loop immediately with ctx.Err(), so shutdown does not block on queued retries.
type SMTPMailer ¶
SMTPMailer delivers via plain net/smtp. The zero value is NOT usable: at a minimum Host and From must be set. Username/Password are optional.
func (*SMTPMailer) Send ¶
func (m *SMTPMailer) Send(ctx context.Context, msg Message) error
Send dispatches msg over SMTP. The MIME body is built locally so the implementation can produce a true multipart/alternative when both bodies are present without dragging in net/mail or net/textproto.
Metrics: the emails_sent_total counter is NOT incremented here; that responsibility lives in the MetricsMailer decorator, which wraps this implementation in cmd/web/main.go. Keeping it out of the leaf mailer avoids double-counting once retries are layered in: the decorator records the final outcome (one observation per Message), not per attempt.