rfc5322
Python's stdlib email.utils.parseaddr is not RFC 5322 conformant — it silently
accepts invalid addresses and mangles valid ones. rfc5322 is a compliant,
dependency-free, spec-tested alternative.
A hand-written recursive-descent parser for the RFC 5322 address grammar
(§3.2–§3.4) plus every obsolete §4.4 production. Zero runtime dependencies,
stdlib only, no regexes used for grammar recognition.
Status: v1.0.0 — 288 tests passing, 99% coverage, CI green on Python 3.12 and 3.13, MIT licensed. Not yet on PyPI (install from GitHub, below).
>>> from rfc5322 import is_valid_address
>>> is_valid_address("a..b@example.com") # stdlib says this is fine
False
Why this exists
email.utils.parseaddr is a pragmatic header-scanner, not a grammar. It is
lenient where the RFC is strict, and lossy where the RFC is precise. Every row
below was executed against CPython 3.12:
| Input | email.utils.parseaddr |
rfc5322 |
|---|---|---|
a..b@example.com |
('', 'a..b@example.com') — accepts a forbidden empty atom |
rejected: expected '@' in addr-spec (offset 1) |
user@[192.0.2.1] |
('', '') — mangles a valid domain literal |
accepted, domain == '[192.0.2.1]' |
a@b@c.com |
('', '') — returns nothing, no error |
rejected: unexpected trailing input '@c.com' (offset 3) |
a@example.com, b@example.com |
('', '') — a list is not one address |
rejected; use parse_address_list |
(comment)john@example.com |
('comment', 'john@example.com') — leaks the comment into the display name |
accepted, comments == ('comment',), display_name is None |
John Q. Public <j@x.com> |
('John Q. Public', 'j@x.com') — accepts an obsolete §4.4 phrase by default |
rejected in strict mode; accepted with strict=False and flagged obsolete |
The stdlib's contract is "best effort"; this package's contract is "the ABNF,
or an error with the byte offset". Divergences are locked down by the
TestStdlibDivergence test class.
Install
Not yet on PyPI — install from GitHub for now.
# From GitHub (latest main):
pip install "git+https://github.com/beduldul/rfc5322.git"
# or with uv:
uv pip install "git+https://github.com/beduldul/rfc5322.git"
# From a clone (editable, with dev tools):
git clone https://github.com/beduldul/rfc5322.git
cd rfc5322
uv venv && uv pip install -e ".[dev]"
Requires Python 3.12+. No runtime dependencies.
Usage
from rfc5322 import (
parse_address, is_valid_address, parse_address_list, parse_mailbox_list,
AddressSyntaxError,
)
# 1. A plain valid address, with a display name and CFWS comments.
addr = parse_address("John Doe (boss) <john.doe@example.com>")
addr.local_part # 'john.doe'
addr.domain # 'example.com'
addr.display_name # 'John Doe'
addr.comments # ('boss',)
addr.normalized # 'john.doe@example.com'
# 2. A quoted local part — quotes are stripped, quoted-pairs decoded.
parse_address('"john doe"@example.com').local_part # 'john doe'
parse_address('"a\\"b"@example.com').local_part # 'a"b'
# 3. Obsolete §4.4 syntax is rejected by default, opt in explicitly.
is_valid_address('user."quoted"@example.com') # False
obs = parse_address('user."quoted"@example.com', strict=False)
obs.local_part, obs.obsolete # ('user.quoted', True)
# 4. Invalid input raises with the exact offset and a caret excerpt.
try:
parse_address("a..b@example.com")
except AddressSyntaxError as exc:
print(exc)
# expected '@' in addr-spec (offset 1)
# a..b@example.com
# ^
CLI
$ python -m rfc5322 'John Doe <john@example.com>'
VALID: local_part='john' domain='example.com'
normalized=john@example.com
display_name='John Doe'
$ python -m rfc5322 --permissive 'user."q"@x.com'
VALID: local_part='user.q' domain='x.com'
normalized=user.q@x.com
(used obsolete §4.4 syntax)
$ python -m rfc5322 'a..b@x.com'; echo "exit=$?"
INVALID: expected '@' in addr-spec (offset 1)
a..b@x.com
^
exit=1
Exit status: 0 valid, 1 invalid, 2 usage error. -p / --permissive
enables the obsolete productions. Installing the package also provides a
rfc5322 console script with the same behaviour.
API
| Function | Purpose |
|---|---|
parse_address(text, *, strict=True) -> Address |
Parse one address (mailbox or group). Raises AddressSyntaxError. |
is_valid_address(text, *, strict=True) -> bool |
Non-raising predicate. |
parse_address_list(text, *, strict=True) -> tuple[Address, ...] |
Parse an address-list. |
parse_mailbox_list(text, *, strict=True) -> tuple[Address, ...] |
Parse a mailbox-list (groups rejected). |
Address is a frozen, slotted dataclass — inputs are never mutated, every
function returns new immutable objects.
Address field |
Meaning |
|---|---|
local_part |
Decoded local part (quotes removed, quoted-pair decoded). |
domain |
Domain; a domain literal keeps its [ ] brackets. |
display_name |
Decoded phrase for name-addr/group, else None. |
comments |
Every CFWS comment, decoded, in source order. |
source |
The original input string. |
is_group / group_members |
Group flag and member addresses. |
obsolete |
True if a §4.4 production was required to accept the input. |
normalized |
Canonical local@domain, or name:members; for a group. |
AddressSyntaxError is a ValueError subclass carrying position and a
caret-annotated excerpt of the offending input.
RFC coverage
| RFC 5322 section | Productions | Status |
|---|---|---|
| §3.2.1 | quoted-pair, obs-qp |
complete |
| §3.2.2 | FWS, obs-FWS |
complete |
| §3.2.3 | CFWS, comment, ccontent, ctext, obs-ctext, atom, dot-atom, dot-atom-text |
complete |
| §3.2.4 | quoted-string, qcontent, qtext, obs-qtext |
complete |
| §3.2.5 | word, phrase, obs-phrase |
complete |
| §3.4 | address, mailbox, name-addr, angle-addr, group, display-name, mailbox-list, address-list, group-list, obs-addr-list, obs-group-list, obs-mbox-list |
complete |
| §3.4.1 | addr-spec, local-part, domain, domain-literal, dtext, obs-local-part, obs-domain, obs-route, obs-angle-addr, obs-dtext |
complete |
| §2.1.1 | 998-character line limit | enforced |
| RFC 5321 §4.5.3.1 | 64-char local part, 255-char domain | enforced |
| RFC 1035 §2.3.4 | 63-char DNS label | enforced |
A production-by-production mapping (production → section → implementation
method → tests) lives in compliance.md.
How this differs from email.utils
- It rejects instead of guessing.
parseaddrreturns('', '')for many malformed inputs and never raises;rfc5322raisesAddressSyntaxErrorwith the byte offset, or returnsFalsefromis_valid_address. - It preserves information the stdlib discards. Comments are decoded into
Address.commentsrather than leaked into the display name; domain literals are kept ([192.0.2.1]) instead of being reduced to an empty string. - It distinguishes modern from obsolete syntax. Obsolete §4.4 forms are
accepted only under
strict=False, and every such parse setsAddress.obsolete = True, so callers can audit legacy mail. - It validates lists.
parse_address_list/parse_mailbox_listhandle §3.4 comma lists (including theobs-*-listforms), whichparseaddrcannot represent at all. - It enforces length limits from RFC 5322 §2.1.1, RFC 5321 and RFC 1035, which the stdlib does not check.
Limitations
Honest list — this parses addresses, not messages:
- No MIME/header parsing. §3.6 fields (
Received,Date,Message-ID,fields/trace/optional-field) are out of scope. - No message body or MIME multipart parsing.
- No §4.5–§4.7 obsolete message syntax (
obs-date,obs-received,obs-message-id). Only the §4.4 addressing productions are implemented. - No semantic/DNS validation. No MX lookup, no existence check — a syntactically valid domain need not exist.
- Domain-literal contents are not interpreted.
[IPv6:...]is validated asdtextonly; IPv4/IPv6 well-formedness is not checked. That belongs to a network layer. - ASCII only. No IDN/IDNA and no SMTPUTF8 (RFC 6531). Non-ASCII input is rejected, because the RFC 5322 grammar is ASCII-only.
- Length limits are stricter than the raw ABNF. The 998/64/255/63 limits are an extra semantic pass; the pure grammar alone would accept longer input.
groupnormalizedoutput is canonical, not byte-identical to the input (display names are decoded but not re-quoted).
Differential testing
The comparison table above is hand-written, so it is exactly the kind of
evidence that collapses when probed. fuzz/ is a seeded differential
harness that checks the claim mechanically against three references:
| Reference | What it is | DNS? |
|---|---|---|
email.utils.parseaddr |
the stdlib scanner the claim is about | no |
email.headerregistry.Address(addr_spec=…) |
CPython's strict addr-spec parser | no |
email_validator |
the widely-used third-party validator | disabled |
email_validator performs DNS/MX lookups by default; the harness passes
check_deliverability=False so the comparison is syntax only and never
touches the network. email_validator is a dev/test-only dependency
(uv run --with email-validator, or the [fuzz] extra) — the package itself
still has zero runtime dependencies.
The corpus is 4160 inputs (160 hand-written + 4000 byte-level mutants),
generated with random.Random(5322). Re-run it deterministically with:
uv run --with email-validator python -m fuzz.run --seed 5322 --mutants 4000
uv run --with email-validator python -m fuzz.run --replay '<input>' # reproduce one failure
Measured results (seed 5322, CPython 3.12):
| Reference | agree | we reject / it accepts | we accept / it rejects | both accept, output differs |
|---|---|---|---|---|
email.utils.parseaddr |
2554 | 1360 | 105 | 141 |
email.headerregistry.Address |
3727 | 177 | 196 | 60 |
email_validator |
3325 | 70 | 754 | 11 |
What the numbers mean. They are not a scoreboard. Against parseaddr the
harness confirms the central claim: it accepts 1360 RFC-invalid inputs the
parser rejects (a..b@example.com, .user@example.com, a b@example.com,
@example.com, "unbalanced@x.com, …) and returns ('', '') for 105 valid
ones it cannot represent (domain literals user@[192.0.2.1], CFWS comments,
groups). Against headerregistry the parser agrees on 89.6% and the residual
divergences are its scope limits (it rejects groups, comments and bare
display names) plus length/ASCII policy. Against email_validator the parser
agrees on 79.9%; the 754 "we accept / it rejects" cases are not parser
bugs — email_validator deliberately rejects domain literals (§3.4.1),
CFWS comments (§3.2.3), quoted local parts and single-label domains, and it
applies IDNA/deliverability semantics that RFC 5322 does not.
Bugs the harness found (fixed). Adversarial fuzzing found three real
defects, all in the same family — inputs accepted only under strict=False
that did not set Address.obsolete, violating the documented invariant:
user. name@x.com(obs-local-part, §4.4) parsed withobsolete=False.john@example\r\n .com(obs-domain, §4.4) parsed withobsolete=False.Group: a@b.com,;(obs-mbox-list, §3.4) parsed withobsolete=False, andparse_mailbox_listdid not implementobs-mbox-listat all despite the coverage table claiming it complete.
Each has a regression test in tests/test_obsolete.py. The pinned counts are
asserted in tests/test_differential.py, so a future change that shifts them
turns CI red rather than being silently absorbed.
Development
uv venv
uv pip install -e ".[dev]"
.venv/bin/python -m pytest --cov=rfc5322 --cov-report=term-missing
.venv/bin/ruff check .
Current status: 288 tests passing, 99% statement coverage, ruff clean.
See PROOF.txt for the verbatim run and CONTRIBUTING.md
before opening a PR.
License
MIT — see LICENSE.
Metadata
Release files for rfc5322 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| rfc5322-1.0.0.tar.gz | 28.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| rfc5322-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 45.0 kB
Release files / rfc5322-1.0.0.tar.gz
| Download URL | rfc5322-1.0.0.tar.gz |
|---|---|
| Size | 28.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
df514a64a23455e0e31ccebe8666d41aa3f319d0e9ec94a8611bbffbcef10730
|
|
BLAKE2b-256 checksum How to use checksums |
75ba7c7f1aeecdab31c27762b3665273037e5b77ee80504b7fd97baedc166737
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency logRelease files / rfc5322-1.0.0-py3-none-any.whl
| Download URL | rfc5322-1.0.0-py3-none-any.whl |
|---|---|
| Size | 16.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
220118d33efa7c4a57808fae6587dd03f46cc1abf6dae054f513d5afb85c3daa
|
|
BLAKE2b-256 checksum How to use checksums |
fd642a3ea839ef2aa8817cf03da0e4a2ccc7d00072f4989581b0d544a5097adc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency log