Delivery log & bounce classification
Purpose. “Did my mail go out? Why did it bounce?” answered without SSH: every delivery attempt Postfix logs, searchable by address, message-id or queue id, with each failure classified as a hard bounce, a temporary problem, a policy rejection, a reputation block, or mail this server throttled itself.
Behaviour.
- Postfix writes its log to the shared
maillogvolume (/var/log/mail/postfix.log, mirrored to the container’s stdout sodocker logs mailserver-postfix-1still works) and rotates it daily (postfix logrotate, gzipped). The worker’sdelivery-logjob (every minute,DELIVERY_LOG_INTERVAL_MINUTES) tails it from a stored cursor (inode + byte offset,worker_state), finishes a rotated file before moving on, and never ingests a line twice. One run reads at most 8 MiB of the live log, leaving the rest for the next, and skips a single line longer than that, recording none of it, rather than stall. - Rotation is detected two ways, and needs both: the inode changed, or the live file is shorter than the saved offset. The inode alone is not enough — ext4 hands an unlinked file’s inode straight back to its replacement, and logrotate’s
copytruncatekeeps the inode deliberately. On either signal the job reads the remainder of the newestpostfix.log.*from the saved offset, then restarts at the top of the live file. This means any logrotate mode is safe, includingcopytruncate, provided the rotated content lands next to the live file aspostfix.log.<suffix>[.gz]— the job takes the lexically last such name, so the suffix must sort chronologically (the shippedpostfix.log.YYYYMMDD-HHMMSS.gzdoes). A truncation with no rotated file present is handled too: the job restarts from the top rather than skipping ahead, so at most the not-yet-copied tail is lost. - Parsed into
delivery_message(queue id → envelope sender, message-id, size) anddelivery_event(one row perto=line: recipient, original recipient, relay,status, DSN, the remote reply verbatim, direction). Direction:inbound(external sender → Dovecot),internal(local → local),outbound(anything relayed out). - Refusals during the SMTP conversation are captured too (
status: rejected, since 2026-09-05): Postfix’sNOQUEUE: reject(a restriction refused it) andNOQUEUE: milter-reject(Rspamd refused it), plus themilter-rejectthat carries a queue id whencleanuphad already assigned one. They never reached the queue, so there is nodelivery_messageto join to — the envelope sender travels on the event itself and readers takecoalesce(event.sender, message.sender). - A
milter-rejectis classifiedreputation, notpolicy. On this stack the only milter is Rspamd, so a milter refusal is our own filter judging the message’s content; outbound, that is the strongest evidence a mailbox is compromised, and Compromised-mailbox containment weightsreputationabove hard bounces. The coupling is real: adding a second milter tomain.cfbreaks this rule, and Postfix does not log which milter spoke. - …except a rate-limit refusal, which is
throttledsince 2026-09-08. Rspamd’sratelimitmodule refuses over the same milter with the same action, so the only tell is its reply text —Ratelimit "<bucket>" exceeded, matched before the reputation rule. This matters because a throttle is this server holding a message back, not anyone’s judgement of the sender: counting it asreputationmeant a mailbox that tripped its own limit during a legitimate burst was judged compromised.throttledis excluded from containment’s ratio on both sides. - Direction for a rejection comes from the envelope, since there is no relay to reason from: a local sender is
outbound(even though the mail never left the building — for containment that is exactly the point), a local sender to a local recipient isinternal, and anything else isinbound, including a relay attempt where neither side is ours. - Classification (
class):delivered(status sent) ·throttled(our own rspamd bucket,Ratelimit "…" exceeded; an SMTP-time refusal only) ·reputation(reply mentions a blocklist, Spamhaus/SpamCop/Barracuda, “listed”, RBL/DNSBL, “spam”, “bulk”, “unsolicited”, “poor sending”, “reputation”) ·policy(DMARC/SPF/DKIM, “not authorized”, relay denied, a remote server’s rate limit, greylisting, DSN 5.7.x/4.7.x) ·hard(bounced/expired/rejected — an SMTP-time rejection is final, so it never falls tosofteven without a DSN — or any other 5.x.x) ·soft(deferred, 4.x.x). Order is the rule: throttled before reputation, and reputation before policy so “blocked using zen.spamhaus.org” is a reputation block even though it is a 5.7.1. The UI labelsthrottledRate limited and does not style it as a failure — a temporary refusal at SMTP time; the sending client retries, and nothing was queued here. GET /delivery-log?q=&class=&status=&direction=&domain=&days=&limit=&offset=returns the page plus counts per class over the same filter — includingclass, so filtering on one class leaves the other tiles at zero (daysdefault 7, at most 90, though onlyDELIVERY_LOG_RETENTION_DAYSare kept, 30 by default;limitdefault 100, at most 500);status=rejectedis the “rejected at the door” view, offered in the UI as the Outcome filter — the class tiles say why mail failed, and this says where, because a rejection is spread across the reputation/policy/hard tiles like a bounce without being one;domain_adminusers see only events touching their domains (sender or recipient side). UI: Delivery log in the sidebar — search box, class/direction/window filters, class tiles (click to filter), pagination; the mailbox page links here pre-filtered by address.- Retention: events older than
DELIVERY_LOG_RETENTION_DAYS(30) are pruned by the same job; rotated log files older than 7 days by the postfix container (docker/common/maillog.sh), because the worker, uid 2525, only reads themaillogdirectory. Postfix writes each new log0600; the postfix container hands it to group 25250640within ten seconds, and a run that meets it before then reportsunreadableand reads it next minute from the same cursor.
Configuration. .env: DELIVERY_LOG_RETENTION_DAYS (default 30). Worker: MAILLOG_DIR (/var/log/mail), DELIVERY_LOG_INTERVAL_MINUTES (1) — compose/compose.yaml passes neither to the worker, so the defaults run and setting them in .env has no effect unless you add them to the service’s environment.
Limits & edge cases. The messages API reads this log through delivery_message.message_id (indexed since migration 0032) to answer GET /messages/{id}, so a message older than the retention window loses its recipient events and its status reads queued (Sending from an application).
- Syslog timestamps carry no year; the parser assumes the current year unless that lands in the future (year rollover).
- A message whose
from=line predates the worker’s first run showssender: null. - SRS-rewritten envelopes appear as
SRS0=…@<hostname>senders on forwarded mail (SRS — sender rewriting for forwarded mail); search by the original sender via the message-id or the original recipient instead. - A rejection has no relay: nothing was ever handed to one. A Postfix restriction refusal (
NOQUEUE: reject) also has no queue id (queueId: null); an Rspamd refusal at end of message usually carries one. Itsclientfield holds the connecting host instead, and the UI shows that in the relay column. It is deduped on a digest of the log line rather than on the queue id, because Postgres treats nulls in a unique index as distinct and every rotation would otherwise re-insert every rejection. reject_warningis deliberately not recorded. Postfix logs it underwarn_if_reject, where the mail is accepted anyway; counting it would show a delivered message as refused and overstate every containment ratio. A rejection atCONNECTorHELOis also skipped: Postfix has no envelope to log yet, so there is no recipient to attribute it to.- Queue management (hold/release/delete) is a separate screen and a separate channel to Postfix (Mail queue (viewer, hold / release / delete)); this log shows what happened, not what is waiting, and a message absent from the queue was not necessarily delivered.