A single message hitting your MX writes something like this to disk:
Jul 4 09:12:44 mx1 postfix/smtpd[2214]: 4Xyz7Q1cKpz1: client=host.example.com[203.0.113.44]
Jul 4 09:12:44 mx1 postfix/cleanup[2219]: 4Xyz7Q1cKpz1: message-id=<[email protected]>
Jul 4 09:12:45 mx1 postfix/qmgr[1180]: 4Xyz7Q1cKpz1: from=<[email protected]>, size=5123, nrcpt=1
Jul 4 09:12:46 mx1 postfix/smtp[2231]: 4Xyz7Q1cKpz1: to=<[email protected]>, relay=..., delay=1.2, dsn=2.0.0, status=sentFour lines, and you have just recorded a real person's IP, their reverse DNS, a precise timestamp, who they emailed, and who emailed them. Under GDPR that is textbook personal data — the CJEU settled in *Breyer* (C-582/14) that a dynamic IP plus a timestamp is identifiable, and an email address identifies directly. Multiply by a few million lines a day and your mail.log becomes the biggest PII archive you operate, with no retention policy, no access control worth the name, and no DPIA. Nobody decided to build a surveillance index. It accreted, one debug line at a time.
The reframe this article runs on: none of the red fields above are load-bearing for debugging. The queue ID 4Xyz7Q1cKpz1 is. Every troubleshooting workflow you actually run keys off the queue ID, not the recipient address. So you can redact identity at ingestion, keep the correlation spine intact, and lose nothing operational.
Anatomy of a leak: what one delivery writes to disk
Postfix logs a message across several daemons, and each field falls into exactly one of three buckets. Personal data: the client= hostname and IP, the from= sender, the to= recipient, the message-id, and — easy to forget — the SASL username on authenticated smtpd lines. Correlation-safe: the queue ID, the timestamp, size=, delay=, dsn=, status=. Noise: ephemeral client ports, most connect from chatter.
The queue ID is the join key. In modern Postfix — long queue IDs have been the default since 2.9 — it is a base-52 token encoding the message's arrival time plus an inode-derived sequence, which is the 4Xyz7Q1cKpz1 you see above; the legacy short form is uppercase hex derived from the queue-file inode. Either way it is unique per message per host and contains zero personal data. Every daemon that touches the message — smtpd, cleanup, qmgr, smtp/lmtp — stamps the same queue ID. That is the entire reason grep 4Xyz7Q1cKpz1 mail.log reconstructs a full session. You debug on the spine, not on the identity hanging off it.
Keep the queue ID, drop the identity
Walk through what you lose. Take the redacted strip above and try to answer the questions you actually ask logs:
- Did this message deliver?
status=sent,dsn=2.0.0— still there. - How long did it sit in the queue?
delay=1.2— still there. - What did the remote say when it bounced? The
dsnand the enhanced status text — still there. - Can I follow the whole session?
grep 4Xyz7Q1cKpz1 mail.log— the queue ID is untouched, so every line fromsmtpdaccept to finalsmtpdelivery still stitches together.
None of that needs [email protected] in cleartext. The recipient address is only genuinely required for one workflow: a *named* user complaint — "message from Alice never reached me" — where you have to correlate on a specific address. That is real, and it is exactly why the answer is pseudonymize, not delete. Hold that thought.
Redact at syslog, not in the app
The instinct is to patch Postfix, or to run a nightly cron that scrubs yesterday's log with sed. Both are wrong. Patching source means you own a fork forever. Post-hoc scrubbing means raw PII lands on persistent storage first, gets backed up, and gets read by whoever tails the file in the gap — you have already lost. The goal is that cleartext identity never touches durable storage.
The choke point is rsyslog. Every daemon — Postfix, Dovecot, Rspamd — logs through it, so it is the one place to enforce policy once. Two mechanisms carry the load: mmanon for IPs, and an external helper for addresses.
# /etc/rsyslog.d/00-mail-redact.conf
module(load="mmanon")
module(load="omprog")
if ($programname startswith "postfix" or $programname startswith "dovecot"
or $programname == "rspamd") then {
# 1. Anonymize IPs in place: zero the last IPv4 octet, anonymize the IPv6 suffix
action(type="mmanon" ipv4.mode="zero" ipv4.bits="8" ipv6.enable="on")
# 2. Hand the IP-anonymized line to the HMAC helper, which pseudonymizes
# addresses and writes the redacted logfile. This action is the sink.
action(type="omprog"
binary="/usr/local/bin/mail-redact.sh"
template="RSYSLOG_TraditionalFileFormat")
stop # don't let the default mail.* rule also persist the cleartext line
}Order and module type matter here. mmanon is a *message-modification* module — it rewrites the IP inside the message in place, so 203.0.113.44 becomes 203.0.113.0 before anything else sees it. omprog, by contrast, is an *output* module: it pipes each line to your helper's stdin and the helper owns persistence. That is why IP anonymization runs first — by the time the line reaches the helper, the host identity is already gone and the helper only has to deal with addresses. The trailing stop is not optional: without it, rsyslog's default mail.* rule writes an untouched cleartext copy right next to your redacted one.
ipv4.mode="zero" ipv4.bits="8" keeps the /24 for abuse triage while dropping the host. If you need stable per-host pseudonyms within a run instead, use ipv4.mode="random-consistent", which maps each source IP to a fixed random value.
Journald-only hosts have no such hook. Either set Storage=volatile so nothing durable is written and forward a redacted copy to rsyslog, or add a forward hop and let rsyslog own the persistent stream. Do not try to redact inside journald — it has no field-rewrite stage.
Pseudonymize with an HMAC, don't just delete
This is where naive scrubbing fails. If you replace every to=<...> with to=<REDACTED>, you have destroyed the one legitimate correlation workflow: "show me every session for [email protected] during the incident window." You can no longer group by recipient, because they are all the same string.
A keyed HMAC fixes this. HMAC-SHA256 of the address gives a stable, deterministic token — same input always yields the same token, so you can still grep and group — but it is not reversible without the key. The helper is small:
#!/bin/bash
# /usr/local/bin/mail-redact.sh — omprog sink: HMAC-pseudonymize addresses, then persist
KEY=$(cat /etc/mail-redact.key)
hmac() { printf '%s' "$1" | openssl dgst -sha256 -hmac "$KEY" -r | cut -c1-16; }
while IFS= read -r line; do
while [[ "$line" =~ \<([^@<> ]+@[^<> ]+)\> ]]; do
addr="${BASH_REMATCH[1]}"
line="${line//<$addr>/<$(hmac "$addr")>}"
done
printf '%s\n' "$line" >> /var/log/mail-redacted.log
doneThe core is printf '%s' "$addr" | openssl dgst -sha256 -hmac "$KEY" -r | cut -c1-16, producing to=<a1b2c3d4e5f6a7b8>. One caveat: a shell that forks openssl per address will not keep up with millions of lines a day — for production volume, reimplement this as a resident process (an mmexternal plugin or a small compiled filter) rather than a per-line fork.
Why HMAC and not plain SHA-256? The email address space is enumerable. If you know a target address, a plain unsalted hash is recovered by a single sha256 call — it is not a secret, it is an index. An HMAC with a secret key is a keyed MAC; without /etc/mail-redact.key an attacker cannot reproduce the token, so a leaked log stays pseudonymous.
Key hygiene:
chmod 0600 /etc/mail-redact.key, ownerroot:root. The helper runs as the rsyslog user, so read the key once at startup or grant that user read access only.- Rotate the key per retention epoch. Rotating deliberately severs correlation across epochs — last quarter's tokens no longer match this quarter's. That is a feature: it caps how far back any single incident can link.
- Store the key outside your log backups, or you have handed the reverser both halves.
The quieter leaks: Dovecot auth and Rspamd
Postfix is not the only offender. Dovecot has two settings that will ruin your day if flipped. In 10-logging.conf / 10-auth.conf:
auth_verbose = yes # fine: logs auth success/fail, no secrets
auth_verbose_passwords = no # MUST stay no — "sha1" logs a truncated SHA1 of the password
auth_debug = no
auth_debug_passwords = no # MUST stay no — logs near-cleartext credentialsauth_verbose = yes is safe and useful — it tells you *that* an auth failed without leaking *what*. The two *_passwords toggles are the trap; auth_debug_passwords = yes will happily write credential material to disk. Also audit the mail_log plugin — it stamps the username on every delete, expunge, and copy. Keep it only if you have an access-audit requirement.
Rspamd is the subtle one because it logs message *content* metadata. Its history and logs capture Subject, From, and extracted URLs. In options.inc:
options {
log_urls = false;
}Restrict the web UI and history to secure_ip ranges, keep it behind auth, and consider disabling detailed history retention entirely. Subjects leak into logs when extended_spam_headers or verbose logging is on — so don't run verbose in steady state.
And back in Postfix: SASL usernames appear on authenticated smtpd lines and are directly identifying — add them to your rewrite rule. Confirm smtpd_client_port_logging = no (the default) and never flip it on; the ephemeral client port is pure noise that only sharpens fingerprinting.
Time-box the verbosity you actually need
People keep noisy logs out of fear — "what if I need it later?" Kill that fear with targeted, temporary verbosity so your steady-state logs can be aggressively redacted. Postfix does per-peer debug without touching global logging. In main.cf:
debug_peer_list = smtp.problematic.example
debug_peer_level = 2postfix reload, reproduce the issue, then revert and reload. That raises verbosity for exactly one remote, for exactly as long as you need it. Dovecot's auth_debug is the same pattern — flip during an incident, revert after. Deep debugging becomes opt-in and time-boxed, which is precisely what lets the default stream stay stripped.
Retention is redaction you get for free
The cheapest PII minimization is not writing the data. The second cheapest is deleting it fast. Split your streams: a redacted long-term stream (ship to Loki or keep in journal, 90 days) and a raw short-lived debug stream on tmpfs (48 hours, gone on reboot).
# /etc/logrotate.d/rsyslog
/var/log/mail.log {
daily
rotate 7
maxage 7
compress
delaycompress
missingok
notifempty
}# /etc/systemd/journald.conf
Storage=volatile
MaxRetentionSec=2day
SystemMaxUse=200MTwo traps. First, backups silently defeat retention — if your nightly job sweeps /var/log, your "7-day" mail log lives for years on tape. Either exclude raw logs from backups or match backup rotation to your retention. Second, legal holds override deletion; carve out a documented, scoped hold process rather than letting a lawsuit turn into "keep everything forever."
Why this is product-aligned, not just compliance
For a temp-email operator the recipient side is a high-cardinality firehose — thousands of burner addresses churning per hour. Storing raw to= next to sender IPs turns mail.log into a "who contacted which burner" index: exactly the linkage the product exists to prevent. Redacting here isn't a checkbox for the auditor, it's keeping the promise the service makes.
Operator checklist
mmanonloaded, zeroing the last IPv4 octet and anonymizing the IPv6 suffix.- Address-rewrite helper tested against a real sample line — confirm
to=,from=, and the SASL username all get tokenized. stoppresent so the defaultmail.*rule doesn't persist a cleartext copy.- HMAC key at
/etc/mail-redact.key,0600 root:root, excluded from backups. - Key rotation scheduled per retention epoch and documented.
- Dovecot:
auth_verbose_passwords = no,auth_debug_passwords = no— verified in effective config, not just the file. - Rspamd:
log_urls = false
Run through it once, and the worst-case answer to "what's in your logs?" becomes queue IDs and status codes — a debugging tool, not a dossier.


