Skip to main content

rfc5322

CI Python 3.12+ License: MIT Coverage: 99% Ruff

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. parseaddr returns ('', '') for many malformed inputs and never raises; rfc5322 raises AddressSyntaxError with the byte offset, or returns False from is_valid_address.
  • It preserves information the stdlib discards. Comments are decoded into Address.comments rather 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 sets Address.obsolete = True, so callers can audit legacy mail.
  • It validates lists. parse_address_list / parse_mailbox_list handle §3.4 comma lists (including the obs-*-list forms), which parseaddr cannot 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 as dtext only; 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.
  • group normalized output 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:

  1. user. name@x.com (obs-local-part, §4.4) parsed with obsolete=False.
  2. john@example\r\n .com (obs-domain, §4.4) parsed with obsolete=False.
  3. Group: a@b.com,; (obs-mbox-list, §3.4) parsed with obsolete=False, and parse_mailbox_list did not implement obs-mbox-list at 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)

Source distribution for rfc5322 1.0.0
File Size Uploaded
rfc5322-1.0.0.tar.gz 28.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for rfc5322 1.0.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

1.0.1

2 release files

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page