Open /var/log/mail.log on any production MTA and read a single line. There is a client IP, a HELO name, sometimes a sasl_username, an envelope sender, one or more recipients, a Message-ID, and a timestamp. That is not "log noise" — under GDPR it is a pile of online identifiers linked to identifiable people, and almost nobody sets a defensible clock on how long it sits there.
The reflex answer — "we delete logs after 30 days" — is somehow both over-compliant and under-compliant at once. It throws away security signal you have a lawful reason to keep, while leaking data through three side channels the 30-day rule never touched: Postfix queue files that outlive your log rotation, delivery-event rows in your application database that outlive both, and backups that silently re-extend everything to a year. Retention is not a policy PDF. It is a deletion-engineering problem with per-class clocks, automated expiry you can prove ran, and an erasure path that doesn't nuke the forensics you legitimately need.
The data you're actually retaining
A mail stack holds at least five distinct classes of personal data, each with a different natural lifetime and a different reason to exist:
- MTA logs —
/var/log/mail.log, journald, per-transaction lines fromsmtpd,qmgr,smtp/lmtp. Client IP, HELO, authenticated username, from, to, subject-length, Message-ID, TLS parameters. - Postfix queue files — the actual messages in flight, under
/var/spool/postfix/{incoming,active,deferred,hold,corrupt}. These hold the full envelope and body, not just metadata. A message stuck indeferredis a copy of someone's email sitting on disk. - Delivery-event rows — bounce, open, click, complaint, and webhook payloads written into your app DB. These usually have the longest, least-governed life, because a developer added the table and no one wired up expiry.
- Filter/scanner logs — rspamd, amavis, OpenDKIM, ClamAV. Same identifiers as the MTA log plus scoring metadata.
- Temp-email metadata — the special case. On evilmail the message *content* is ephemeral by design, deleted minutes to an hour after arrival. But the mailbox record, the arrival timestamps, and the sender fingerprints are metadata that can outlive the content and quietly become the real leak.
Anyone who thinks IP-plus-timestamp is anonymous should read *Breyer v Germany* (CJEU C-582/14, 2016): a dynamic IP address is personal data in the hands of a party who can lawfully obtain the means to identify the subscriber. Recital 30 names online identifiers explicitly. So the whole list above is in scope, and Article 5(1)(e) storage limitation puts the burden on you to justify every day you keep it.
Here is the anatomy, concretely — a normal successful delivery across three log lines:
postfix/smtpd[2114]: NOQUEUE: connect from mail.example.org[203.0.113.7]
postfix/qmgr[1990]: 8F2A11C043: from=<[email protected]>, size=4821, nrcpt=1
postfix/smtp[2117]: 8F2A11C043: to=<[email protected]>, relay=..., delay=1.2,
status=sent (250 2.0.0 OK)Four personal-data elements in three lines: the sending IP, the envelope sender, the recipient, and the correlating queue ID that stitches them together. Multiply by a few million lines a day and "just keep the logs" is a standing liability.
The retention clock: one matrix, a lawful basis per class
The core deliverable is a table you write down, date, and defend — not a single TTL. Pick the shortest window that still satisfies a *documented* purpose. Anything longer needs a written justification, because that is exactly what Article 5(2) accountability asks you to produce.
- Postfix queue files — 1 to 5 days. Governed by
maximal_queue_lifetimeandbounce_queue_lifetime. Lawful basis: contract/legitimate interest (you're trying to deliver the mail). Default is 5 days; shrink it to reduce how long undelivered bodies sit on disk. - MTA transaction logs — 30 days (7 days if you crank up verbose debug logging). Lawful basis: legitimate interest, Art. 6(1)(f) — deliverability triage and postmaster disputes rarely need more than a month of history.
- Auth / security logs — 90 days. Lawful basis: legitimate interest in anti-abuse and account-security investigations. This is the class that justifies keeping *more* than 30 days, and you should say so in the matrix.
- Delivery events — 90 to 180 days, then anonymize. Raw rows for 90 days; aggregate or pseudonymize for the tail so trend analytics survive without the addresses.
- Temp-email metadata — mailbox TTL. Content in minutes to an hour; metadata purged when the mailbox expires. Only a salted abuse fingerprint survives.
The timeline below is the whole argument in one picture — a blanket TTL cannot be right when the lanes differ this much in length.
Making expiry automatic and provable
Automatic is half the job. Provable is the other half — Article 5(2) means you must be able to demonstrate that deletion actually happened, not merely that a policy exists.
Start with logrotate. The knob that matters for GDPR is maxage, not rotate — rotate caps file count, maxage deletes by wall-clock age regardless of count:
# /etc/logrotate.d/mail
/var/log/mail.log /var/log/mail.err {
daily
rotate 30
maxage 30
compress
delaycompress
missingok
notifempty
postrotate
/usr/bin/systemctl kill -s HUP rsyslog.service >/dev/null 2>&1 || true
endscript
}That postrotate HUP is not optional. Delete a log file and rsyslog keeps writing to the held inode — the file is "gone" from ls but the data lives on until the daemon reopens. If you need a secure wipe rather than a plain unlink (shred is not a logrotate directive), add a lastaction block that runs find /var/log -name 'mail.log-*' -mtime +30 -exec shred -u {} \;.
For journald, cap retention in /etc/systemd/journald.conf and force a vacuum:
# journald.conf: MaxRetentionSec=30day SystemMaxUse=2G
systemctl restart systemd-journald
journalctl --vacuum-time=30d
journalctl --disk-usage # verifyPostfix queue hygiene lives in main.cf, plus manual cleanup of the corrupt/hold backlog:
postconf -e 'maximal_queue_lifetime = 3d'
postconf -e 'bounce_queue_lifetime = 1d'
postfix reload
postqueue -p | tail -1 # queue depth
postsuper -d ALL deferred # purge stuck deferred bodiesFor the application DB, a row-by-row DELETE FROM delivery_events WHERE created_at < now() - interval '90 days' works at small scale but bloats and needs aggressive vacuuming on large event tables. Prefer time-based partitioning and drop whole partitions — expiry becomes O(1):
-- monthly partitions; drop the ones past window
ALTER TABLE delivery_events DETACH PARTITION delivery_events_2026_03;
DROP TABLE delivery_events_2026_03;
-- and emit the accountability proof for THIS run
INSERT INTO audit_log (job, ran_at, rows_or_files_deleted, window_cutoff)
VALUES ('delivery_events_expiry', now(), 148223, now() - interval '90 days');That last insert is the part most teams miss. Every automated purge should write one audit row — job name, run timestamp, count purged, the cutoff it used — into a separate audit_log that is retained long-term. When a regulator asks "prove you delete," you show the ledger, not a cron file.
Wire the DB job to a systemd timer, not a bare crontab line, so failures surface in systemctl status and journald:
# /etc/systemd/system/mail-retention.timer
[Timer]
OnCalendar=daily
Persistent=trueHonoring erasure without blinding your abuse defense
Routine expiry and an Article 17 erasure request are different code paths. Expiry is time-driven and blind to identity; erasure is identity-driven and immediate. Conflating them is how teams end up either ignoring DSARs or over-deleting evidence they were entitled to keep.
Locating one data subject means a cross-store sweep — log lines by address or Message-ID, event rows by an indexed lookup:
grep -F '[email protected]' /var/log/mail.log* # + rotated
zgrep -F '[email protected]' /var/log/mail.log-*.gzDELETE FROM delivery_events WHERE recipient = '[email protected]';You do not always have to fully erase. Article 17(3) carves out exemptions, and Art. 6(1)(f) legitimate interest lets you keep data tied to an active fraud or abuse case. But the defensible move is to pseudonymize rather than retain raw: truncate IPv4 to /24 (IPv6 to /48), and replace the address with a keyed HMAC so it stays abuse-correlatable within a rotation window but is not reversible:
UPDATE delivery_events
SET recipient = encode(hmac('[email protected]', current_salt, 'sha256'), 'hex'),
client_ip = network(set_masklen(client_ip, 24))
WHERE recipient = '[email protected]'
AND case_id IN (SELECT id FROM abuse_cases WHERE status = 'open');Note network(set_masklen(...)), not set_masklen(...) alone — the latter only rewrites the prefix length and leaves the host bits (203.0.113.7) intact; wrapping it in network() zeroes them to 203.0.113.0/24, which is what actually anonymizes the address.
For temp-email the pattern is cleanest: content erasure is instant and unconditional, but you keep a salted hash of the abuse fingerprint rather than the raw address — enough to block a repeat abuser, nothing that reconstructs who received what.
Backups, replicas, and "deleted but not really"
A retention policy is a lie if your nightly pg_dump and filesystem snapshots keep 90-day-old logs for a year. This is where most audits fall apart. Three honest options:
- Exclude high-churn log tables from long-retention backups entirely (
--exclude-table=delivery_events), and back them up on a short cycle only. - Crypto-shred. Encrypt each backup cycle with a rotating key. When the key for an expired cycle is destroyed, that backup is unrecoverable — no need to surgically edit archive files.
- Document backup retention as its own data class with its own clock and lawful basis.
Then say the honest thing to your DPA: erasure from backups happens "at next restore or at backup-cycle expiry," not instantly. Supervisory authorities accept that position *when it is documented* — it is the undocumented year-long snapshot that gets you fined. Watch replica lag too: deleting on the primary while a read replica or a WAL archive retains the row for days is the same leak wearing a different hat.
Operator checklist
- Retention matrix written, dated, and stored — one row per data class with window, lawful basis, and deletion mechanism.
maxageset on every mail log in logrotate, with apostrotateHUP so rsyslog releases inodes.- journald capped (
MaxRetentionSec,SystemMaxUse) and vacuumed on restart. maximal_queue_lifetimeandbounce_queue_lifetimesane (1–5d);postsupercleanup scheduled.- DB retention runs from a systemd timer, prefers partition-drop, and writes an audit row per run.
- DSAR runbook with the grep and SQL one-liners, plus the pseudonymization update for retain-but-mask cases.
- Backup retention documented as its own class; crypto-shred key rotation enabled; replica/WAL lag accounted for.
The test of a retention policy is not whether the document exists. It is whether, on a Tuesday afternoon, you can point a regulator at an audit_log table and say: this job ran, it deleted this many rows, using this cutoff, on this date — and here is the one address we kept, hashed, because it belonged to an open abuse case. Everything in this article exists to let you say exactly that.


