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>
<!-- ============================================================
ALERT RULE (starter) — put this in a RULES file, e.g.
/var/ossec/etc/rules/local_rules.xml. Decoders and rules live
in SEPARATE files. The rule matches the decoder above through
<decoded_as>; set <level> and add <field>/<match> conditions so
it alerts only on the events you care about. Rule ids 100000+
are the user range — change them if they collide with yours.
============================================================ -->
<group name="postfix,">
<rule id="100000" level="3">
<decoded_as>postfix-syslog3164</decoded_as>
<description>postfix: decoded event</description>
</rule>
</group>
- 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 added a starter alert <rule> (level 3, matched to the decoder via <decoded_as>) — put it in a RULES file (not the decoders file), set the level, and add <field>/<match> conditions; the commented example child rule shows the pattern
- 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
Wazuh's OS_Regex is not PCRE — a bare . is a literal dot and \. matches any character.
Test Wazuh OS_Regex patterns →
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)
Splunk
# props.conf (search-time extraction)
[<REPLACE_WITH_SOURCETYPE>]
EXTRACT-logforge = (?<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]+))?
# Quick search-time test in SPL:
# | rex field=_raw "(?<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]+))?" - note EXTRACT-<class> names must be unique within a sourcetype stanza — rename EXTRACT-logforge if you already use that class for this sourcetype
- note a timestamp field was detected: this EXTRACT only makes it a searchable field. To set the event _time at index time, add TIME_PREFIX and TIME_FORMAT to this props.conf stanza (TIME_FORMAT uses Splunk strptime, e.g. %Y-%m-%dT%H:%M:%S) — this generator does not guess the strptime format.
ES ingest
PUT _ingest/pipeline/postfix
{
"description": "LogForge-generated ingest pipeline for postfix",
"processors": [
{
"grok": {
"field": "message",
"patterns": [
"%{SYSLOGTIMESTAMP:timestamp} mail01 postfix/smtpd\\[%{NUMBER:pid}\\]: NOQUEUE: reject: RCPT from %{DATA:literal}: 554 5\\.7\\.1 %{NOTSPACE:literal2} %{NOTSPACE:literal3}(?: %{GREEDYDATA:literal4})?"
]
}
}
]
} - note grok: constant field "hostname" embedded as literal anchor "mail01" (varying=false)
- note grok: constant field "number" embedded as literal anchor "554" (varying=false)
- note grok: 1 optional field(s) wrapped in (?:…)? inline regex — grok has no native optional syntax
- note test in Kibana Dev Tools with: POST _ingest/pipeline/postfix/_simulate (supply a docs[] array whose _source.message holds a sample line)
Graylog
# --- Graylog processing pipeline rule (primary) ---
# Paste under System > Pipelines > Manage rules, then attach the rule to a pipeline stage.
rule "postfix-parse"
when
has_field("message")
then
let gp = grok(pattern: "%{SYSLOGTIMESTAMP:timestamp} mail01 postfix/smtpd\\[%{NUMBER:pid}\\]: NOQUEUE: reject: RCPT from %{DATA:literal}: 554 5\\.7\\.1 %{NOTSPACE:literal2} %{NOTSPACE:literal3}(?: %{GREEDYDATA:literal4})?", value: to_string($message.message), only_named_captures: true);
set_fields(gp);
end
# --- Graylog import-ready extractor JSON (secondary) ---
# Save as a .json file and import under System > Inputs > (input) > Manage extractors > Actions > Import extractors.
{
"extractors": [
{
"title": "postfix",
"extractor_type": "grok",
"converters": [],
"order": 0,
"cursor_strategy": "copy",
"source_field": "message",
"target_field": "",
"extractor_config": {
"grok_pattern": "%{SYSLOGTIMESTAMP:timestamp} mail01 postfix/smtpd\\[%{NUMBER:pid}\\]: NOQUEUE: reject: RCPT from %{DATA:literal}: 554 5\\.7\\.1 %{NOTSPACE:literal2} %{NOTSPACE:literal3}(?: %{GREEDYDATA:literal4})?",
"named_captures_only": true
},
"condition_type": "none",
"condition_value": ""
}
],
"version": "5.0.0"
} - note grok: constant field "hostname" embedded as literal anchor "mail01" (varying=false)
- note grok: constant field "number" embedded as literal anchor "554" (varying=false)
- note grok: 1 optional field(s) wrapped in (?:…)? inline regex — grok has no native optional syntax
- note primary artifact is the processing-pipeline rule; the extractor JSON is an equivalent import-ready alternative for the classic extractor UI
Datadog
logforge_rule %{date("MMM dd HH:mm:ss"):timestamp} mail01 postfix/smtpd\[%{number:pid}\]: NOQUEUE: reject: RCPT from %{notSpace:literal}: 554 5\.7\.1 %{notSpace:literal2} %{notSpace:literal3}(?: %{notSpace:literal4})? - note emitted rule name is "logforge_rule"; rename it to match your "postfix" convention if desired
- 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 (?:…)? — Datadog Grok has no native optional matcher; a chain of optional columns may need a Helper Rule per shape
- note paste this line into a Grok Parser processor in a Datadog Log Pipeline; matchers are anchored left-to-right and rule whitespace matches log whitespace. Complex or multi-shape logs may need Helper Rules.
Fluent Bit
[PARSER]
Name postfix
Format regex
Regex ^(?<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]+))?$
Time_Key timestamp
Time_Format %b %d %H:%M:%S
# Fluentd <parse> block:
# <parse>
# @type regexp
# expression /^(?<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]+))?$/
# time_key timestamp
# time_format %b %d %H:%M:%S
# </parse>
- note Time_Key set to "timestamp"; Time_Format "%b %d %H:%M:%S" is a best-effort strptime derived from the sample shape — verify it against your data (Fluent Bit uses %L for fractional seconds and %z for numeric offsets)
Vector
[transforms.postfix_parse]
type = "remap"
inputs = ["REPLACE_WITH_SOURCE"]
source = '''
. |= parse_regex!(.message, r'(?P<timestamp>[A-Za-z]+ \d+ \d+:\d+:\d+) mail01 postfix/smtpd\[(?P<pid>-?\d+(?:\.\d+)?)\]: NOQUEUE: reject: RCPT from (?P<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 (?P<literal2>[A-Za-z]+) (?P<literal3>[A-Za-z]+)(?: (?P<literal4>[A-Za-z]+))?')
''' Loki
# promtail pipeline for "postfix" (generated by LogForge)
# Add these stages under a scrape_config in your promtail config:
# scrape_configs:
# - job_name: postfix
# pipeline_stages:
# (the stages below are indented to sit under pipeline_stages)
pipeline_stages:
- regex:
expression: '^(?P<timestamp>[A-Za-z]+ \d+ \d+:\d+:\d+) mail01 postfix/smtpd\[(?P<pid>-?\d+(?:\.\d+)?)\]: NOQUEUE: reject: RCPT from (?P<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 (?P<literal2>[A-Za-z]+) (?P<literal3>[A-Za-z]+)(?: (?P<literal4>[A-Za-z]+))?$'
- note no low-cardinality field found to promote to a Loki label — omitted the `- labels:` stage; every captured field stays in the extracted map for later stages
- note left in the extracted map (NOT promoted to labels — high cardinality would explode Loki streams): timestamp, pid, literal, literal2, literal3, literal4
syslog-ng
parser p_postfix {
regexp-parser(
prefix(".postfix.")
patterns("(?<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]+))?")
);
}; - note captured fields are stored as name-value pairs under the prefix ".postfix." (e.g. a group (?<srcip>…) becomes ".postfix.srcip")
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 →