Parsing Email Headers from Scratch: What Every Received Line Actually Means
Email headers are a stack of metadata appended every time a message hops between servers. This is a developer-focused walkthrough of how to parse Received lines, what each token means, and how to reconstruct a message's full delivery path from raw text.
A Received: header is the breadcrumb a mail server prepends to every message it accepts. Read top to bottom, a stack of them tells you the exact route a message took in reverse chronological order: which server handed it off, which IP it came from, which TLS cipher was used, how long the handoff took, and what the receiving server decided to call it. Parse one and you can debug almost any deliverability mystery without guessing.
This post works through Received: headers from the protocol up, for developers who have to write a parser, a log enricher, an abuse detector, or a script that explains why one customer's mail is two hours late. The format is governed by RFC 5321 section 4.4, which defines the SMTP trace fields, and RFC 5322 section 3.6.7, which defines how trace headers appear in the final message. Together they specify the grammar; every real-world Received: line you will ever see is a loose implementation of it.
If you want the plain-English tour of the whole header block rather than the parser-writer's version, start with understanding email headers instead.
What a Received line is for
Every time an SMTP server accepts a message, it prepends a Received: header above the existing headers. "Prepend" is the magic word; the newest hop is at the top, the original sender is at the bottom, and the header stack is a literal call trace.
Four things follow. Path reconstruction becomes trivial, because the timestamps tell you exactly which hop ate the four hours. Spoofing gets harder, since a From: header is trivially forgeable but a Received: line written by Gmail's MX naming the sending IP is not; anything a spammer fakes gets overwritten by the hops above them. Abuse reporting gets concrete, because the first line above the spam names the IP and ASN you escalate to. And forensics gets fast: if a message claims to be from your bank and the bottom-most line names a residential IP in another country, you have your answer.
Here is one real line, dissected over the rest of this post:
Received: from mail.example.com (mail.example.com. [203.0.113.42])
by mx.google.com with ESMTPS id abc123-def456ghi.78.2026.05.14.09.27.04
for <alice@example.org>
(version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
Wed, 14 May 2026 09:27:05 -0700 (PDT)
That single line carries nine distinct facts.
The grammar of a Received line
The formal ABNF in RFC 5321 looks intimidating; the working grammar is short. A Received: header is the header name, followed by zero or more from/by/via/with/id/for clauses in any order (though from and by almost always come first), optionally followed by parenthesized comments, ending in a semicolon and a timestamp.
Every line in the wild follows roughly this shape:
Received: from <SENDER_HOST> (<SENDER_HOSTNAME_FROM_PTR> [<SENDER_IP>])
by <RECEIVER_HOST>
with <PROTOCOL>
id <QUEUE_ID>
for <ENVELOPE_RECIPIENT>
(<COMMENT, usually TLS info>);
<DATE>
Every token there is optional except the header name. Servers routinely skip pieces, reorder them, or stuff extra information into comments; your parser has to be tolerant or it will die on the third message it sees.
The from clause: who said they were
from mail.example.com (mail.example.com. [203.0.113.42])
The unparenthesized hostname is what the sender claimed during the SMTP HELO or EHLO greeting. The parenthesized hostname is what the receiver got from a reverse DNS lookup on the sender's IP. The square-bracketed value is the IP itself.
Those three values can disagree, and the disagreement is the interesting part. Matching HELO and PTR is normal. A mismatch means the sender is misconfigured or lying, and most spam filters score it negatively either way. No PTR at all (unknown, or just the bare IP in the parens) means the sending IP has no reverse DNS; Google's bulk sender guidance requires forward-confirmed reverse DNS above 5,000 messages per day to Gmail, so this one has direct sender reputation consequences.
Trust the bracketed IP above everything else in the line. Hostnames are advisory; the IP is what the TCP stack actually saw.
The by clause: who received it
by mx.google.com
The receiving server. Combined with the timestamp it gives you an unambiguous "this host received the handoff at this time." Debugging a delay means reading the by clauses bottom to top, oldest to newest, and looking at the gap between consecutive timestamps.
Some implementations include the receiver's IP and software identifier:
by mx.google.com with ESMTPS (gsmtp google.com)
The parenthesized comment after by is non-standard but useful; Google, Microsoft, and Yahoo all put identifying information there. Your MX records determine which host ends up in this field for inbound mail.
The with clause: which protocol
with ESMTPS
The with token names the SMTP variant the handoff used.
| Token | Meaning |
|---|---|
SMTP | Plain old SMTP from RFC 821/5321. Rare today. |
ESMTP | Extended SMTP, the modern baseline. Uses EHLO instead of HELO. |
ESMTPS | ESMTP plus STARTTLS; the connection was encrypted. |
ESMTPA | ESMTP plus SMTP AUTH; the sender authenticated to the server. |
ESMTPSA | ESMTP plus STARTTLS plus SMTP AUTH. Common on submission ports (587). |
LMTP | Local Mail Transfer Protocol, used by mailbox stores like Dovecot. |
BSMTP | Batch SMTP, used by gateways that batch messages. |
ESMTPS and ESMTPSA are what you want on hops crossing the public internet. Plain ESMTP means the message rode an unencrypted link; that is increasingly rare and worth flagging when you see it.
The id clause: the receiver's queue ID
id abc123-def456ghi.78.2026.05.14.09.27.04
Whatever the receiving server wants to call this message internally, and the unique key in their queue. If you ever have to ask Google or Microsoft to investigate a delivery on your behalf, this is the first thing their support team will ask for.
Queue IDs are not standardized, so every MTA picks its own format, and after a while you can identify the MTA from the ID alone. Postfix uses a 10 to 12 character base-62 string. Exim uses three sets of letters and digits separated by hyphens (1abcDE-000ABC-1g). Sendmail uses an 8-digit hex string. Microsoft uses a long opaque string beginning with a timestamp.
The for clause: the envelope recipient
for <alice@example.org>
The for clause names the SMTP RCPT TO, which is the envelope recipient. That is often different from the To: header. Mailing lists are the classic case: a message addressed To: list@example.org is delivered to each subscriber, and each subscriber's copy carries for <subscriber@theirhost.com> even though the visible To: still says the list address.
This is also the field that leaks BCC in some configurations. BCC boss@yourcompany.com through a server that writes for clauses and the boss's copy carries for <boss@yourcompany.com>; other recipients can't see it, but the boss can tell they were BCCed by reading the trace. Public MTAs like Gmail and Outlook strip the for clause when forwarding externally for exactly this reason. Private MTAs frequently do not, because inside the perimeter trace fidelity usually wins over recipient privacy.
The parenthesized TLS comment
(version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256)
Non-standardized but near-universal. The triple of version, cipher, and bits describes the TLS session for this hop, and it is your only evidence when auditing for downgrade attacks or for hops that fell back to something ancient.
Google adds more detail in its own lines:
(using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits)
key-exchange X25519 server-signature RSA-PSS (2048 bits))
Microsoft uses a different vocabulary again. Treat the parenthesized comment as free-form key-value text; assuming a fixed schema here is how parsers break in production.
The timestamp
Wed, 14 May 2026 09:27:05 -0700 (PDT)
RFC 5322 section 3.3 defines this format precisely. The day-of-week is optional, the numeric timezone offset is required, and the parenthesized abbreviation (PDT here) is informational and frequently wrong; trust the offset.
For path reconstruction, parse everything to UTC. The gap between consecutive timestamps is time spent in transit between those two hops, and a 30-minute gap on a hop that normally takes 200 ms is your delay.
Reading the full stack
Here is a complete trace for a message sent from Outlook to a Gmail user, with a corporate forwarder on the end:
Received: from mail.gmail.com (mail.gmail.com. [142.250.190.5])
by mx.recipient.com with ESMTPS id ee123.456
for <user@recipient.com>
(version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
Wed, 14 May 2026 09:27:30 -0700 (PDT)
Received: from NAM12-MW2-obe.outbound.protection.outlook.com
([52.100.158.42])
by mx.gmail.com with ESMTPS id m7-20020a05600c138900b003eaf12345
for <user@gmail.com>
(version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
Wed, 14 May 2026 09:27:04 -0700 (PDT)
Received: from MW4PR12MB5678.namprd12.prod.outlook.com (2603:10b6:303:178::6)
by MN2PR12MB1234.namprd12.prod.outlook.com (2603:10b6:208:18a::11)
with Microsoft SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384)
id 15.20.4567.12; Wed, 14 May 2026 16:27:01 +0000
Received: from MW4PR12MB5678.namprd12.prod.outlook.com ([fe80::a1b2:c3d4:5678])
by MW4PR12MB5678.namprd12.prod.outlook.com ([fe80::a1b2:c3d4:5678%9])
with Microsoft SMTP Server id 15.20.4567.12 via Mapi; Wed, 14 May 2026 16:27:00 +0000
Bottom to top, in chronological order:
- 16:27:00 UTC The sender's Outlook client deposits the message on their Exchange server
MW4PR12MB5678over MAPI, not SMTP, which is why the line carriesvia Mapi. - 16:27:01 UTC Exchange relays internally to
MN2PR12MB1234over TLS 1.2. - 16:27:04 UTC (09:27:04 PDT) Microsoft's outbound MX hands off to Gmail's MX over TLS 1.3.
- 16:27:30 UTC (09:27:30 PDT) Gmail forwards to the corporate forwarder
mx.recipient.com; note thefor <user@recipient.com>, which is the forwarder's view of where to deliver.
Thirty seconds end to end. The biggest gap, 26 seconds, sits between Microsoft's outbound hop and Gmail's MX, which is where Gmail did its gateway-level scoring. The final forwarding step is what happens when a Google Workspace domain forwards to an external mailbox, and recognizing it explains why the message landed at mx.recipient.com rather than in a Gmail mailbox.
Building a parser
A minimal, defensive pattern that survives real-world input. The approach is to treat the line as a sequence of <token> <value> clauses, where the value runs until the next known token or the final ;.
import re
from datetime import datetime
from email.utils import parsedate_to_datetime
KNOWN_TOKENS = {"from", "by", "via", "with", "id", "for"}
def parse_received(line: str) -> dict:
# Strip the header name if present
if line.lower().startswith("received:"):
line = line[9:].strip()
# Split the date off the end
if ";" in line:
body, _, date_part = line.rpartition(";")
try:
timestamp = parsedate_to_datetime(date_part.strip())
except (TypeError, ValueError):
timestamp = None
else:
body, timestamp = line, None
# Normalize whitespace; CFWS comments are kept inline
body = re.sub(r"\s+", " ", body).strip()
# Tokenize: walk left-to-right, collecting clauses
result = {"timestamp": timestamp}
words = body.split(" ")
i, current_token = 0, None
current_value = []
while i < len(words):
word = words[i].lower().rstrip(":")
if word in KNOWN_TOKENS:
if current_token:
result[current_token] = " ".join(current_value).strip()
current_token = word
current_value = []
else:
current_value.append(words[i])
i += 1
if current_token:
result[current_token] = " ".join(current_value).strip()
return result
Five things that toy parser does not handle, and a production version must.
Folded headers, first. RFC 5322 lets a header wrap across lines with leading whitespace, so unfold (replace \r\n[ \t] with a single space) before you parse anything. Nested comments come next; RFC 5322 comments can nest, and a for clause may contain one, so walk parentheses with a depth counter rather than a regex. Quoted strings are legal (for "<weird recipient>") and must be treated as a single token. IPv6 addresses in brackets look like normal bracketed IPs but contain colons, so never split on a colon. And timestamps vary more than you'd like; some old systems omit commas, use timezone names instead of offsets, or write two-digit years. Python's email.utils.parsedate_to_datetime handles most of them, and dateutil catches the survivors.
Once you have a clean structured record per line, joining them in array order gives you the full trace; reverse the array to read it chronologically.
Authentication-Results: the receiver's verdict
The Received: stack tells you the path. Authentication-Results: tells you what the receiver decided about authentication. It is technically separate but added by the same hop, so you usually want to read them together.
Authentication-Results: mx.google.com;
dkim=pass header.i=@example.com header.s=selector1 header.b=Abc123de;
spf=pass (google.com: domain of bounces@example.com designates 198.51.100.1 as
permitted sender) smtp.mailfrom=bounces@example.com;
dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=example.com
The format is defined in RFC 8601. Split on ; after the first field (the authserv-id), then for each method (dkim=, spf=, dmarc=, arc=, bimi=) read the result and the ptype.property=value pairs.
For DMARC the practical question is always the same: did it pass, and does header.from match what your application thinks the sender was? SPF authorizes the envelope sender's IP and DKIM signs the message; DMARC is the policy layer that ties either one back to the visible From domain. The implementation details are in SPF, DKIM, and DMARC for developers.
Tools and shortcuts
If you would rather not write your own parser, Google's Messageheader tool takes a pasted raw header and produces a per-hop summary with timestamps and delays, and Microsoft's Header Analyzer does the same with more detail on Microsoft-specific fields. Mailneo's email header analyzer parses the full stack including authentication results and explains each field in plain English.
For programmatic use, Python's email module (specifically email.parser.BytesParser) handles RFC 5322 parsing and folded headers correctly, and Node's mailparser package does the same. Both save weeks of edge-case work.
Pitfalls I have been burned by
Comments can contain anything, including parentheses, colons, and the literal strings from or by. A regex-based parser will get this wrong the first time it meets an adversarial input, which is a specific hazard when the thing you're parsing is spam.
Some MTAs skip the trace header entirely. Internal handoffs between processes on the same machine often add nothing, so you may see a gap between the user's client and the first external MX with no internal hops named at all.
Time skew is real. A Received: timestamp is whatever the receiving host's clock said at that moment, so if two consecutive hops disagree by 30 seconds, clock drift is a likelier explanation than transit time.
Trust boundaries, Return-Path, and legacy TLS
Three questions the grammar itself does not answer.
Can a sender modify Received lines?
They can write whatever they want into lines they prepend to a message they are forwarding, but they cannot alter lines added by upstream servers, because those sit above their own in the stack. For inbound mail, the top-most line, the one added by your own MX, is the one you can fully trust. Everything below it is a claim.
How does Return-Path: relate to the from clause?
Return-Path: is a single header added by the final delivery agent, containing the envelope sender from MAIL FROM. The from clause of a Received: line is one server's record of who handed it the message at that specific hop. Both ultimately derive from the SMTP envelope, but Return-Path: is canonical for the message while Received: from is per-hop and can differ at every step.
Is with ESMTPS plus version=TLS1_0 a problem?
Yes. TLS 1.0 has been deprecated for years and most modern security policies treat it as effectively unencrypted. If a hop in your trace fell back that far, audit the sending and receiving MTAs for outdated configuration; TLS 1.2 should be the floor and 1.3 the default.
Explore: Email Deliverability
Related Articles
Understanding Email Headers: A Technical Guide
Email headers are the metadata that rides along with every message; they tell you where a mail came from, every server it touched, whether SPF, DKIM, and DMARC passed, and why a message got delayed, bounced, or flagged as spam.
SPF, DKIM, and DMARC for Developers Who Just Want Their App's Email to Land in the Inbox
A practical, opinionated walkthrough of the three DNS records your app needs to send transactional or product email that doesn't land in spam. Written for developers who would rather ship than read RFCs.
SPF vs DKIM vs DMARC: Email Authentication Explained
SPF, DKIM, and DMARC are three DNS-based email authentication standards that together verify senders, protect message integrity, and tell inbox providers what to do with spoofed mail. Skip any one of them and your deliverability suffers.
Return-Path vs Reply-To: What Each Email Header Does
Return-Path and Reply-To solve different problems. Return-Path is the bounce address used by mail servers for delivery failures; Reply-To is the address a human reply should go to. Mixing them up breaks bounce handling, support routing, and deliverability reporting.
Ready to supercharge your email marketing?
Start sending smarter emails with AI-powered campaigns. No credit card required.
Get Started Free