bokamba / logforge / parse / Postfix mail

$ logforge parse postfix

Parse Postfix mail logs → regex, Grok, Wazuh & rsyslog

Postfix, the mail transfer agent that moves a large share of the internet's email, logs through syslog to the mail facility — landing in /var/log/mail.log on Debian/Ubuntu or /var/log/maillog on RHEL. Each line is a standard RFC 3164 syslog record (Jul 3 14:22:15 mail01) followed by a program tag that names the specific Postfix subprocess and its PID: postfix/smtpd[2210], postfix/qmgr[2201], postfix/cleanup, postfix/smtp, and so on. That subprocess name is the single most useful thing in the line, because it tells you which stage of mail handling the event belongs to and therefore what the rest of the message means — smtpd is the inbound server accepting (or rejecting) connections, qmgr is the queue manager, smtp is outbound delivery, and cleanup handles header rewriting.

The message body is free-form and stage-specific, which is what makes Postfix logs hard to parse and to correlate. An smtpd rejection like 'NOQUEUE: reject: RCPT from unknown[203.0.113.99]: 554 5.7.1 Service unavailable' packs in the reject reason, the SMTP command it happened on (RCPT), the reverse-DNS/IP of the sender (unknown[203.0.113.99] — 'unknown' meaning no valid PTR record), and the SMTP status and enhanced status codes (554 and 5.7.1). A qmgr line like '4Z8xY1: from=<[email protected]>, size=15320, nrcpt=1 (queue active)' is keyed by a queue ID (4Z8xY1) and reports the envelope sender, message size, and recipient count. The queue ID is the join key: a single message threads through smtpd, cleanup, qmgr, and smtp lines that all share the same hex-ish queue ID, and to reconstruct one delivery you must group by that ID across multiple lines — which is the core reason Postfix log analysis is genuinely multi-line correlation rather than single-line parsing.

For mail security and deliverability the fields that matter are the client IP and PTR (spotting spam sources and hosts with no reverse DNS), the reject reason and SMTP status code (554/5.7.1 rejections indicate policy or reputation blocks), the envelope from= and to=/nrcpt (tracing who sent what to whom), and the queue ID that stitches the stages together. Counting NOQUEUE rejects per client IP surfaces spam and dictionary attacks; watching status= results on outbound smtp lines (sent, deferred, bounced) tracks delivery health.

Open this in LogForge →

What a Postfix mail line looks like

The syslog sample below is fed verbatim into the engine to produce every parser on this page.

Jul  3 14:22:15 mail01 postfix/smtpd[2210]: NOQUEUE: reject: RCPT from unknown[203.0.113.99]: 554 5.7.1 Service unavailable
Jul  3 14:22:41 mail01 postfix/smtpd[2213]: NOQUEUE: reject: RCPT from mail.spam.example[198.51.100.7]: 554 5.7.1 Client host rejected

Detected fields

The engine classified this sample as syslog3164 and consolidated 14 fields across 2 lines. Fields marked literal were identical on every sample line, so they are baked into the pattern as anchors rather than captured.

  • timestamp : timestamp
  • hostname : hostname · literal
  • program : literal · literal
  • pid : number
  • _lit1 : literal · literal
  • _lit2 : literal · literal
  • _lit3 : literal · literal
  • _lit4 : literal · literal
  • literal : literal
  • number : number · literal
  • _lit5 : literal · literal
  • literal2 : literal
  • literal3 : literal
  • literal4 : literal

Regex (named capture groups)

# sample: Jul  3 14:22:15 mail01 postfix/smtpd[2210]: NOQUEUE: reject: RCPT from unknown[203.0.113.99]: 554 5.7.1 Service unavailable
# groups: timestamp=Jul  3 14:22:15, pid=2210, literal=unknown[203.0.113.99], literal2=Service, literal3=unavailable
^(?<timestamp>[A-Za-z]+  \d+ \d+:\d+:\d+) mail01 postfix/smtpd\[(?<pid>-?\d+(?:\.\d+)?)\]: NOQUEUE: reject: RCPT from (?<literal>(?:[A-Za-z]+\.[A-Za-z]+\.[A-Za-z]+\[\d+\.\d+\.\d+\.\d+\]|[A-Za-z]+\[\d+\.\d+\.\d+\.\d+\])): 554 5\.7\.1 (?<literal2>[A-Za-z]+) (?<literal3>[A-Za-z]+)(?: (?<literal4>[A-Za-z]+))?$

Grok pattern (Logstash / Elastic)

%{SYSLOGTIMESTAMP:timestamp} mail01 postfix/smtpd\[%{NUMBER:pid}\]: NOQUEUE: reject: RCPT from %{DATA:literal}: 554 5\.7\.1 %{NOTSPACE:literal2} %{NOTSPACE:literal3}(?: %{GREEDYDATA:literal4})?
  • note constant field "hostname" embedded as literal anchor "mail01" (varying=false)
  • note constant field "number" embedded as literal anchor "554" (varying=false)
  • note 1 optional field(s) wrapped in (?:…)? inline regex — grok has no native optional syntax

Wazuh decoder (OS_Regex XML)

<!--
  Generated by LogForge - Wazuh decoder (OS_Regex dialect, not PCRE)
  sample: Jul  3 14:22:15 mail01 postfix/smtpd[2210]: NOQUEUE: reject: RCPT from unknown[203.0.113.99]: 554 5.7.1 Service unavailable
  test with: /var/ossec/bin/wazuh-logtest
-->

<decoder name="postfix-syslog3164">
  <program_name>^postfix/smtpd$</program_name>
</decoder>
  • note syslog header fields handled by Wazuh pre-decoding (not re-parsed): timestamp, hostname, program, pid
  • note parent matches by <program_name> (1 program(s) seen in the samples) — extend the alternation for other programs
  • note field "literal" has no safe OS_Regex pattern before the ":" terminator — template truncated; field(s) omitted: literal, number, _lit5, literal2, literal3, literal4
  • note no message fields could be captured — parent decoder emitted alone
  • note decoder order and prematch specificity may need site-specific tuning (other decoders in your ruleset can shadow these) — validate with /var/ossec/bin/wazuh-logtest

rsyslog template / liblognorm rulebase

# rsyslog template — put in /etc/rsyslog.d/postfix.conf
# Emits the detected RFC 3164 header fields plus the raw
# message body as JSON-ish text using standard rsyslog properties.
# Bind the template to an action, e.g.:
#   action(type="omfile" file="/var/log/postfix.log" template="postfix")
# A literal "%" inside a template string must be escaped as "%%".
template(name="postfix" type="string" string="{\"timestamp\":\"%timereported%\",\"host\":\"%hostname%\",\"program\":\"%programname%\",\"procid\":\"%procid%\",\"msg\":\"%msg:::json%\"}\n")

# --- mmnormalize rulebase for the message body ----------------------
# rsyslog properties cover only the syslog header. To extract the fields
# inside the message body, save the lines below (starting at 'version=2',
# which must be the VERY FIRST line of the file) as /etc/rsyslog.d/postfix.rb
# and load them with:
#   module(load="mmnormalize")
#   action(type="mmnormalize" rulebase="/etc/rsyslog.d/postfix.rb")
version=2
rule=postfix_msg: NOQUEUE: reject: RCPT from %literal:char-to{"extradata":":"}%: 554 5.7.1 %literal2:word% %literal3:word% %literal4:word%
rule=postfix_msg: NOQUEUE: reject: RCPT from %literal:char-to{"extradata":":"}%: 554 5.7.1 %literal2:word% %literal3:word%
  • note header property mapping: timestamp -> %timereported%, hostname -> %hostname%, program -> %programname%, pid -> %procid%
  • note message-body fields cannot be extracted by rsyslog properties alone — an mmnormalize (liblognorm v2) rulebase for the msg part is appended below the template; save it as its own .rb file
  • note dropped header separator "]:" before the first message field (it belongs to the syslog tag, not the msg property)
  • note chosen parser types: literal=char-to(:), literal2=word, literal3=word, literal4=word
  • note optional columns (literal4): liblognorm has no optional parts within a single rule — emitted a second rule variant with only the always-present columns (max 2 variants; lines with other column combinations will not match and need extra rule= lines)

FAQ

What is the Postfix queue ID and why does it matter?
The queue ID (e.g. 4Z8xY1) is the identifier Postfix assigns to a message once it is queued. It appears at the start of every log line for that message across smtpd, cleanup, qmgr, and smtp, so it is the join key for reconstructing a full delivery. NOQUEUE means the message was rejected before ever getting a queue ID.
How do I correlate Postfix log lines for a single email?
Group lines by queue ID. One message produces multiple entries from different subprocesses — reception (smtpd), header cleanup (cleanup), queueing (qmgr), and delivery (smtp) — all sharing the same queue ID. Ordering those by timestamp reconstructs the message lifecycle; you cannot get the full picture from any single line.
What does "NOQUEUE: reject" with unknown[IP] mean?
NOQUEUE means Postfix rejected the message during the SMTP conversation, before assigning a queue ID. unknown[203.0.113.99] means the connecting client's IP has no valid reverse-DNS (PTR) record — a common spam heuristic. The trailing codes (554 5.7.1) are the SMTP reply and enhanced status, indicating a policy or reputation rejection.
Which Postfix subprocess logs the reject and delivery events?
smtpd (the SMTP server) logs inbound connections and rejects; qmgr (queue manager) logs queue events; smtp (the delivery client) logs outbound delivery results (sent/deferred/bounced); cleanup logs header processing. The subprocess name in the program tag — postfix/smtpd[…] — tells you which stage produced the line and how to interpret its message.

Try it on your own Postfix mail lines

Paste a few real lines, review the detected fields, and copy whichever format your stack needs. Free, no account, nothing uploaded.

Open this sample in LogForge →