Skip to content

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 maillog volume (/var/log/mail/postfix.log, mirrored to the container’s stdout so docker logs mailserver-postfix-1 still works) and rotates it daily (postfix logrotate, gzipped). The worker’s delivery-log job (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 copytruncate keeps the inode deliberately. On either signal the job reads the remainder of the newest postfix.log.* from the saved offset, then restarts at the top of the live file. This means any logrotate mode is safe, including copytruncate, provided the rotated content lands next to the live file as postfix.log.<suffix>[.gz] — the job takes the lexically last such name, so the suffix must sort chronologically (the shipped postfix.log.YYYYMMDD-HHMMSS.gz does). 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) and delivery_event (one row per to= 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’s NOQUEUE: reject (a restriction refused it) and NOQUEUE: milter-reject (Rspamd refused it), plus the milter-reject that carries a queue id when cleanup had already assigned one. They never reached the queue, so there is no delivery_message to join to — the envelope sender travels on the event itself and readers take coalesce(event.sender, message.sender).
  • A milter-reject is classified reputation, not policy. 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 weights reputation above hard bounces. The coupling is real: adding a second milter to main.cf breaks this rule, and Postfix does not log which milter spoke.
  • …except a rate-limit refusal, which is throttled since 2026-09-08. Rspamd’s ratelimit module 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 as reputation meant a mailbox that tripped its own limit during a legitimate burst was judged compromised. throttled is 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 is internal, and anything else is inbound, 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 to soft even 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 labels throttled Rate 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 — including class, so filtering on one class leaves the other tiles at zero (days default 7, at most 90, though only DELIVERY_LOG_RETENTION_DAYS are kept, 30 by default; limit default 100, at most 500); status=rejected is 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_admin users 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 the maillog directory. Postfix writes each new log 0600; the postfix container hands it to group 2525 0640 within ten seconds, and a run that meets it before then reports unreadable and 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 shows sender: 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. Its client field 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_warning is deliberately not recorded. Postfix logs it under warn_if_reject, where the mail is accepted anyway; counting it would show a delivered message as refused and overstate every containment ratio. A rejection at CONNECT or HELO is 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.