mailer

package
v0.0.0-...-fe64ff3 Latest Latest
Warning

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

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 14 Imported by: 0

README

mailer

Outbound-email abstraction. Two leaf implementations (SMTPMailer, LogMailer) deliver the message; three composable decorators add cross-cutting concerns without touching the leaves.

Composition

                       Send(ctx, msg)
                              |
                              v
                +-----------------------------+
                |  LoggingMailer (outermost)  |   one breadcrumb per Send
                +-----------------------------+
                              |
                              v
                +-----------------------------+
                |       MetricsMailer         |   one counter obs per Send
                +-----------------------------+
                              |
                              v
                +-----------------------------+
                |      RetryingMailer         |   up to 3 attempts, expo backoff
                +-----------------------------+
                              |
                              v
                +-----------------------------+
                |  SMTPMailer / LogMailer     |   actual delivery (or log)
                +-----------------------------+

The wiring lives in cmd/web/main.go. Order matters:

  • Retries innermost. Metrics + logs see a single end-to-end outcome per Send, not one per attempt. Counting per-attempt would inflate the failure rate and obscure the user-visible success rate.
  • Metrics inside logs. The log entry is the human-readable mirror of the metric tick; keeping them adjacent (metrics first, log last) means a noisy log line corresponds 1:1 with a counter increment.

Adding a new decorator

  1. New file internal/mailer/<concern>.go. Embed inner Mailer, expose a New<Concern> constructor.
  2. Implement Send(ctx, msg) error — delegate to inner.Send and add exactly one behaviour around it. If the behaviour requires fan-out (alerting, archiving, dual-write to a queue), put each side behind its own decorator and compose them.
  3. Unit-test with the in-package fakeMailer (records calls + returns a configured error sequence).
  4. Wire it into cmd/web/main.go in the correct slot. Update the comment block + this diagram.

Reasonable candidates next: RateLimitedMailer (per-recipient leaky bucket), CircuitBreakerMailer (open the breaker after N consecutive SMTP failures), SamplingMailer (drop a configurable percentage in load tests).

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

View Source
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

type Config struct {
	Host     string
	Username string
	Password string
	From     string
}

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

func (m *LogMailer) Send(ctx context.Context, msg Message) error

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.

func (*LoggingMailer) Send

func (l *LoggingMailer) Send(ctx context.Context, msg Message) error

Send logs the outbound attempt, delegates to the inner mailer, and logs the failure (if any). The returned error is the inner error verbatim so upstream callers can still type-check it.

type Mailer

type Mailer interface {
	Send(ctx context.Context, msg Message) error
}

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.

func New

func New(cfg Config, logger logrus.FieldLogger) Mailer

New returns the Mailer implementation matching cfg: SMTPMailer when Host is set, LogMailer otherwise. The LogMailer fallback emits a single WARN at startup so an operator forgetting to wire SMTP in staging cannot miss it.

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.

func (*MetricsMailer) Send

func (m *MetricsMailer) Send(ctx context.Context, msg Message) error

Send delegates to the inner mailer and records exactly one emails_sent_total observation tagged by Kind and outcome ("success"/"failure"). It returns the inner error verbatim.

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

type SMTPMailer struct {
	Host     string
	Username string
	Password string
	From     string
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL