The one constraint that dictates everything
Stand up a naive "privacy" forwarder and here is what it actually does the moment a destination server hiccups: it writes your inbound message — full body, headers, attachments — into /var/spool/postfix/deferred, where it sits on disk for the default maximal_queue_lifetime of 5 days. Meanwhile Postfix cheerfully logs the recipient, the queue ID, and (with the wrong milter chain) the Subject and Message-ID into mail.log, which your distro rotates weekly and keeps for a month by default. Weeks of correspondence metadata, readable by anyone with root or a subpoena. That service has a privacy *policy*. It does not have privacy.
The design invariant for a real forward-and-forget relay fits in one sentence: message content exists only in RAM for the duration of the SMTP transaction, and the only thing that persists is an opaque `alias → destination` row plus counters. Everything else in this article is derived from that rule. If a decision would put a byte of the message body, subject, or Message-ID onto durable storage or into a log, it's wrong by construction.
Two honest exceptions exist, and we'll name them up front rather than pretend they don't: (1) when the downstream server returns a 4xx, the message *must* sit in a queue on disk until retry, and (2) the alias-to-real-address mapping is persistent by definition — that's the whole product. Everything else can and must be RAM-only.
Data model: what you're allowed to store
The temptation is to store the destination address in plaintext so lookups are trivial. Don't. If your alias table is alias, destination, enabled, then a database dump *is* a correspondence graph tying every pseudonym to a real person. Split the two jobs the destination field does — lookup and delivery — and satisfy them separately:
- Lookup key:
HMAC-SHA256(server_key, destination). Lets you enforce per-destination rate caps and spot a real address enrolled under 40 aliases, without ever storing that address in queryable form. - Delivery value: the destination address sealed with
AES-256-GCMunder a key that lives in the app, not the database. Reversible only in the running process, at delivery time, in RAM.
The full row is roughly alias, dest_hmac, dest_ciphertext, enabled, created_at, msgs_forwarded, last_seen. Note what is *absent*: no Subject, no body, no Message-ID, no original From, no per-message rows at all. Counters are aggregate. The routing metadata persists because it must; the message content never enters this table because it must not.
The GDPR framing here isn't a nicety, it's the point. Data minimization means a breach or a legal demand yields an alias graph — pseudonym-to-ciphertext — not the contents of anyone's mail. You cannot be compelled to produce logs you architected yourself out of keeping.
SRS and DKIM: why naive forwarding bounces
Here's the trap that kills every first attempt at a forwarder. You receive mail From: [email protected] and forward it to [email protected], preserving that From. Outlook checks SPF for gmail.com against your relay's IP — which obviously isn't in Gmail's SPF record — so SPF fails. Gmail publishes p=reject, so DMARC evaluation at Outlook rejects the message outright. Your "forwarding" service silently bounces every message from any domain with a strict DMARC policy, which in 2026 is most of them.
SRS (Sender Rewriting Scheme) fixes the return path. You rewrite the envelope sender (MAIL FROM, not the visible header) to a signed local address so bounces route back through you and SPF aligns on your domain:
HHH is a keyed hash, TT a timestamp (so the token expires and can't be replayed to turn you into an open bounce relay), then the original domain and localpart. Second-hop chaining uses the SRS1 form. In Postfix you run postsrsd on 127.0.0.1:10001/10002 and wire it in as a canonical rewrite:
sender_canonical_maps = tcp:127.0.0.1:10001
sender_canonical_classes = envelope_sender
recipient_canonical_maps = tcp:127.0.0.1:10002
recipient_canonical_classes = envelope_recipient, header_recipientSRS fixes the bounce path but does nothing for the DMARC check on the original From. That's what ARC (Authenticated Received Chain) is for. ARC lets your relay assert "I verified this message passed DKIM/SPF when it arrived — trust my seal." You add three header fields per hop — AAR (ARC-Authentication-Results), AMS (ARC-Message-Signature), and AS (ARC-Seal) — instance-numbered i=1,2,3…, each seal covering the chain before it with cv=none|pass|fail. A destination that participates in ARC (Gmail, Outlook, and Yahoo all do) sees a valid chain carrying the upstream dkim=pass and can override the SPF failure its own DMARC engine would otherwise hit. The milter that does this is OpenARC.
Crucially, on a *pure* relay you do not touch the body — so the original DKIM signature stays intact and validates at the destination on its own. The single most common way people break forwarding is bolting on a "Forwarded by EvilMail" footer or a List-Unsubscribe header, which mutates the signed content and invalidates DKIM. Don't modify the body. ARC-seal it and pass it through byte-for-byte.
The zero-retention Postfix path
The relay's master.cf should treat local delivery as impossible — there's no final destination on this box, only onward SMTP. The content path streams through the milter chain; the queue is a transient buffer, not a store. The knobs that turn Postfix from a 5-day spool into a forward-and-forget pipe:
# Queue is a retry buffer, not storage. Give up fast.
maximal_queue_lifetime = 1h
bounce_queue_lifetime = 1h
minimal_backoff_time = 300s
maximal_backoff_time = 900s
# Never become an open relay — this line is load-bearing.
smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_destination
# OpenARC seals inline, in RAM. (SRS is done via the canonical maps above.)
smtpd_milters = inet:127.0.0.1:8893
non_smtpd_milters = inet:127.0.0.1:8893
milter_default_action = tempfailmaximal_queue_lifetime = 1h is the honest exception made small. When Outlook returns 451 4.7.0 try again later, the message *has* to live somewhere; it lives in the active/deferred queue on disk for at most one hour of retries, then it's gone. That one-hour window is the entire disk-retention surface of the service, and — see the checklist — deferred-queue depth is the metric you alarm on, because a growing queue is literally the shape of retention accumulating.
reject_unauth_destination stays no matter what. Drop it and you've built an open relay that every spammer on earth will find within hours.
Logging you can defend
Stock Postfix logging is a metadata leak. A single delivery line carries the queue ID, the recipient, the message size, and the delay; add a verbose milter and you get the Subject and Message-ID too. For a privacy relay, a log line should answer "did alias X forward successfully at time T" and nothing else.
Before:
Jul 4 11:22:01 relay postfix/smtp[8821]: A3F2B: to=<[email protected]>,
relay=outlook.com[...], delay=1.2, dsn=2.0.0, status=sent, message-id=<[email protected]>After — cut the correlation between queue ID and content, keep only alias plus result:
Jul 4 11:22:01 relay evilmail-fwd: alias=7f3a status=sent code=250You get there by isolating the relay with syslog_name = evilmail-fwd, keeping Postfix's default facility out of your retained mail.log, and having the milter emit the one aggregate line above. Then let logrotate finish the job — rotate 0 on the raw mail log means it never accumulates history; you ship only the aggregate counters to your metrics store. No Message-ID ever reaches durable storage, so there's no index tying a queue event back to a conversation.
Rate limiting, abuse, and reputation
A relay is a semi-open forwarder, which means spammers will try to ride it and mailbox providers will judge your sending IP by the worst alias on it. Three defenses, reusing the same RATE_LIMITS pattern the rest of EvilMail already relies on:
- Per-alias and per-destination caps. Messages per hour on the alias (a normal newsletter alias runs single digits; 400/hour means it's compromised) plus an aggregate cap on the destination HMAC to catch one real address fanning traffic across many aliases.
- Disable, don't delete. When an alias trips a limit, flip
enabled=false. The mapping stays for audit and re-enablement; you never had the content to lose anyway. - Reject at RCPT, never bounce. This is the backscatter rule. If you accept a message and *then* discover you can't deliver it, you generate an NDR to a forged return-path and become a backscatter source. Reject in
smtpd_recipient_restrictionsat RCPT TO so the *sending* MTA owns the bounce. Your relay never emits an NDR to an address it didn't verify.
For feedback loops (FBL) from the big providers, process the ARF report's *headers* — the complained-about alias — to auto-disable it. You never need to open the complained-about message body, and you shouldn't; you don't have it anyway.
Checklist: shipping a forward-and-forget relay
- RAM-only transaction path verified — no
mailbox/localfinal delivery inmaster.cf maximal_queue_lifetimeandbounce_queue_lifetimebounded to ≤ 1h- Deferred-queue depth monitored and alarmed — your retention canary
- SRS running (postsrsd), signing keys on a rotation schedule
- ARC sealing on (OpenARC), instance chaining verified as
cv=passat a real Gmail/Outlook target - DKIM policy decided: pass-through unmodified body; never add a footer or
List-*header - Destination stored as HMAC (lookup) + AES-256-GCM (delivery), never plaintext; no per-message rows
- Logs stripped to
alias + timestamp + result code
The two persistences you can't engineer away — the transient queue on a 4xx and the alias-to-address map — are the honest boundary of the design. Everything else, every subject line and body byte and Message-ID that a lazy forwarder keeps for weeks, simply never touches disk. That's the difference between a privacy policy and privacy you can prove.


