Decisions

ADR-044: Email logging records shape; body content is never written to a log sink

`pkg/email` ships a `LogSender` for dev/CI so operators can see that a send "happened" without wiring a real provider. The same instinct — "dump everything so we can debug" — applied to email bodies leaks secrets at an…

#044

ADR-044: Email logging records shape; body content is never written to a log sink

Status: Accepted Date: 2026-04-21

Context

pkg/email ships a LogSender for dev/CI so operators can see that a send "happened" without wiring a real provider. The same instinct — "dump everything so we can debug" — applied to email bodies leaks secrets at an industrial scale:

  • Password-reset mails contain one-time links whose only defence is that they are unguessable.
  • Verification codes are bearer tokens with minute-scale lifetime.
  • Receipts carry amounts, account names, and order IDs.
  • Magic-link auth sends the whole session in the body.

The three-layer redactor in pkg/observ handles known sensitive-key names and common token formats, but is explicitly documented as "defence-in-depth, not a contract" (see ADR-007). It is a bad strategy to rely on substring matching to catch the body of every transactional email we will ever send.

Decision

No Sender implementation in pkg/email writes body content (Text or HTML) to a log sink. The LogSender.Send path emits:

msg=email.sent
from=<addr> to=[<addrs>] cc=[<addrs>] bcc=[<addrs>]
subject=<subject> tags=[<tags>]
bytes_text=<int> bytes_html=<int>

…and nothing more. bytes_text / bytes_html are present as shape signals: operators can confirm "something of the expected size was sent to the expected address" without the content. Human-readable Writer-mode output follows the same shape (a single line with the same fields).

This is not an opt-in. There is no flag to turn on body logging. Logging policy is a property of the sender type, not its configuration. Services that need a body in a debug context reproduce the send in a local fixture against Recorder, where the body is available in memory but never goes near a log sink.

The same rule binds any future logging Sender added to this package (e.g. a tracing-heavy debug sender). If a future implementation needs bodies, it is a different package with a different name and a different risk conversation.

Consequences

Easier:

  • The dev workflow still sees what matters: "the email was sent to alice@example.com with subject Welcome at 08:12:03". That covers 90% of the debugging questions ("did we try to send?", "to whom?", "how big was it?") without the 10% of cases where the answer requires the body.
  • Loki / stdout logs are safe to grep, ship to archive, and replay in demos without a redaction pass.
  • The Subject field is permitted to be logged — services that encode user-identifying information into subjects (e.g. "Re: Your order #ORD-9342") accept that. Keep subjects generic when you do not want them in a log.

Harder:

  • Debugging an "did the rendered HTML actually contain X?" question requires running the sender locally against Recorder, not reading a prod log. Acceptable: the same workflow that a unit test uses.
  • If a product decides it wants body logging for a legitimate regulated reason (audit requirement, for example), they cannot just turn on a flag. They either log it themselves at a different layer under a different sink (pkg/audit/sink) or they propose a new decision that supersedes this one.

Signed up to operate:

  • Every new Sender in this package must pass the "no body content in logs" review bar. A breach lands as a P1 on the same tier as a redactor regression (ADR-007 "swordfish" probe).
  • The bytes_text / bytes_html fields and the email.sent canonical message are a stable dashboard contract. Alerts and Grafana queries key off them; renaming either is a breaking change for the observability surface.

Alternatives considered

  • Full-body logging for LogSender only — rejected. LogSender is the dev/CI sender; dev/CI environments leak. CI runners are on shared infrastructure with stderr that is aggregated and retained. "Only in dev" is not a property we can enforce once the code is in the binary.
  • Make LogSender a no-op (NullSender) — rejected. Losing the "the send happened" signal kills the dev ergonomic that motivates having a LogSender in the first place. Shape-only is the middle ground: enough signal to debug, nothing to leak.
  • Body-logging flag with an in-prod kill switch — rejected. Flag state is drift waiting to happen. "The staging flag was off but the prod flag was on because we copied the env file" is a class of incident that does not need inventing for a problem we can solve by design.

Supersedes: none Superseded by: