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.comwith subjectWelcomeat 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
Subjectfield 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_htmlfields and theemail.sentcanonical 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
LogSenderonly — rejected.LogSenderis the dev/CI sender; dev/CI environments leak. CI runners are on shared infrastructure withstderrthat is aggregated and retained. "Only in dev" is not a property we can enforce once the code is in the binary. - Make
LogSendera 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: —