rfc5322 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rfc5322-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdul Afif Al Kaysan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
rfc5322-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,314 @@
1
+ Metadata-Version: 2.4
2
+ Name: rfc5322
3
+ Version: 1.0.0
4
+ Summary: RFC 5322 conformant email address parser — dependency-free, spec-tested, with full obsolete-grammar support
5
+ Author-email: Abdul Afif Al Kaysan <alkaysan07@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/beduldul/rfc5322
8
+ Project-URL: Repository, https://github.com/beduldul/rfc5322
9
+ Project-URL: Source, https://github.com/beduldul/rfc5322
10
+ Project-URL: Issues, https://github.com/beduldul/rfc5322/issues
11
+ Project-URL: Changelog, https://github.com/beduldul/rfc5322/blob/main/CHANGELOG.md
12
+ Keywords: rfc5322,rfc 5322,email,e-mail,address,parser,validation,abnf,mailbox,addr-spec
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Topic :: Communications :: Email
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.12
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.6; extra == "dev"
31
+ Provides-Extra: fuzz
32
+ Requires-Dist: email-validator>=2.0; extra == "fuzz"
33
+ Dynamic: license-file
34
+
35
+ # rfc5322
36
+
37
+ [![CI](https://github.com/beduldul/rfc5322/actions/workflows/ci.yml/badge.svg)](https://github.com/beduldul/rfc5322/actions/workflows/ci.yml)
38
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
40
+ [![Coverage: 99%](https://img.shields.io/badge/coverage-99%25-brightgreen.svg)](https://github.com/beduldul/rfc5322)
41
+ [![Ruff](https://img.shields.io/badge/lint-ruff-clean-brightgreen.svg)](https://github.com/astral-sh/ruff)
42
+
43
+ **Python's stdlib `email.utils.parseaddr` is not RFC 5322 conformant — it silently
44
+ accepts invalid addresses and mangles valid ones. `rfc5322` is a compliant,
45
+ dependency-free, spec-tested alternative.**
46
+
47
+ A hand-written recursive-descent parser for the RFC 5322 `address` grammar
48
+ (§3.2–§3.4) **plus every obsolete §4.4 production**. Zero runtime dependencies,
49
+ stdlib only, no regexes used for grammar recognition.
50
+
51
+ **Status:** v1.0.0 — 288 tests passing, 99% coverage, CI green on Python 3.12 and
52
+ 3.13, MIT licensed. Not yet on PyPI (install from GitHub, below).
53
+
54
+ ```python
55
+ >>> from rfc5322 import is_valid_address
56
+ >>> is_valid_address("a..b@example.com") # stdlib says this is fine
57
+ False
58
+ ```
59
+
60
+ ## Why this exists
61
+
62
+ `email.utils.parseaddr` is a pragmatic header-scanner, not a grammar. It is
63
+ lenient where the RFC is strict, and lossy where the RFC is precise. Every row
64
+ below was executed against CPython 3.12:
65
+
66
+ | Input | `email.utils.parseaddr` | `rfc5322` |
67
+ |---|---|---|
68
+ | `a..b@example.com` | `('', 'a..b@example.com')` — accepts a forbidden empty atom | rejected: `expected '@' in addr-spec (offset 1)` |
69
+ | `user@[192.0.2.1]` | `('', '')` — **mangles** a valid domain literal | accepted, `domain == '[192.0.2.1]'` |
70
+ | `a@b@c.com` | `('', '')` — returns nothing, no error | rejected: `unexpected trailing input '@c.com' (offset 3)` |
71
+ | `a@example.com, b@example.com` | `('', '')` — a list is not one address | rejected; use `parse_address_list` |
72
+ | `(comment)john@example.com` | `('comment', 'john@example.com')` — leaks the comment into the display name | accepted, `comments == ('comment',)`, `display_name is None` |
73
+ | `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` |
74
+
75
+ The stdlib's contract is "best effort"; this package's contract is "the ABNF,
76
+ or an error with the byte offset". Divergences are locked down by the
77
+ `TestStdlibDivergence` test class.
78
+
79
+ ## Install
80
+
81
+ **Not yet on PyPI — install from GitHub for now.**
82
+
83
+ ```sh
84
+ # From GitHub (latest main):
85
+ pip install "git+https://github.com/beduldul/rfc5322.git"
86
+ # or with uv:
87
+ uv pip install "git+https://github.com/beduldul/rfc5322.git"
88
+
89
+ # From a clone (editable, with dev tools):
90
+ git clone https://github.com/beduldul/rfc5322.git
91
+ cd rfc5322
92
+ uv venv && uv pip install -e ".[dev]"
93
+ ```
94
+
95
+ Requires Python 3.12+. No runtime dependencies.
96
+
97
+ ## Usage
98
+
99
+ ```python
100
+ from rfc5322 import (
101
+ parse_address, is_valid_address, parse_address_list, parse_mailbox_list,
102
+ AddressSyntaxError,
103
+ )
104
+
105
+ # 1. A plain valid address, with a display name and CFWS comments.
106
+ addr = parse_address("John Doe (boss) <john.doe@example.com>")
107
+ addr.local_part # 'john.doe'
108
+ addr.domain # 'example.com'
109
+ addr.display_name # 'John Doe'
110
+ addr.comments # ('boss',)
111
+ addr.normalized # 'john.doe@example.com'
112
+
113
+ # 2. A quoted local part — quotes are stripped, quoted-pairs decoded.
114
+ parse_address('"john doe"@example.com').local_part # 'john doe'
115
+ parse_address('"a\\"b"@example.com').local_part # 'a"b'
116
+
117
+ # 3. Obsolete §4.4 syntax is rejected by default, opt in explicitly.
118
+ is_valid_address('user."quoted"@example.com') # False
119
+ obs = parse_address('user."quoted"@example.com', strict=False)
120
+ obs.local_part, obs.obsolete # ('user.quoted', True)
121
+
122
+ # 4. Invalid input raises with the exact offset and a caret excerpt.
123
+ try:
124
+ parse_address("a..b@example.com")
125
+ except AddressSyntaxError as exc:
126
+ print(exc)
127
+ # expected '@' in addr-spec (offset 1)
128
+ # a..b@example.com
129
+ # ^
130
+ ```
131
+
132
+ ### CLI
133
+
134
+ ```sh
135
+ $ python -m rfc5322 'John Doe <john@example.com>'
136
+ VALID: local_part='john' domain='example.com'
137
+ normalized=john@example.com
138
+ display_name='John Doe'
139
+
140
+ $ python -m rfc5322 --permissive 'user."q"@x.com'
141
+ VALID: local_part='user.q' domain='x.com'
142
+ normalized=user.q@x.com
143
+ (used obsolete §4.4 syntax)
144
+
145
+ $ python -m rfc5322 'a..b@x.com'; echo "exit=$?"
146
+ INVALID: expected '@' in addr-spec (offset 1)
147
+ a..b@x.com
148
+ ^
149
+ exit=1
150
+ ```
151
+
152
+ Exit status: `0` valid, `1` invalid, `2` usage error. `-p` / `--permissive`
153
+ enables the obsolete productions. Installing the package also provides a
154
+ `rfc5322` console script with the same behaviour.
155
+
156
+ ## API
157
+
158
+ | Function | Purpose |
159
+ |---|---|
160
+ | `parse_address(text, *, strict=True) -> Address` | Parse one `address` (mailbox or group). Raises `AddressSyntaxError`. |
161
+ | `is_valid_address(text, *, strict=True) -> bool` | Non-raising predicate. |
162
+ | `parse_address_list(text, *, strict=True) -> tuple[Address, ...]` | Parse an `address-list`. |
163
+ | `parse_mailbox_list(text, *, strict=True) -> tuple[Address, ...]` | Parse a `mailbox-list` (groups rejected). |
164
+
165
+ `Address` is a frozen, slotted dataclass — inputs are never mutated, every
166
+ function returns new immutable objects.
167
+
168
+ | `Address` field | Meaning |
169
+ |---|---|
170
+ | `local_part` | Decoded local part (quotes removed, `quoted-pair` decoded). |
171
+ | `domain` | Domain; a domain literal keeps its `[` `]` brackets. |
172
+ | `display_name` | Decoded `phrase` for `name-addr`/`group`, else `None`. |
173
+ | `comments` | Every CFWS comment, decoded, in source order. |
174
+ | `source` | The original input string. |
175
+ | `is_group` / `group_members` | Group flag and member addresses. |
176
+ | `obsolete` | `True` if a §4.4 production was required to accept the input. |
177
+ | `normalized` | Canonical `local@domain`, or `name:members;` for a group. |
178
+
179
+ `AddressSyntaxError` is a `ValueError` subclass carrying `position` and a
180
+ caret-annotated excerpt of the offending input.
181
+
182
+ ## RFC coverage
183
+
184
+ | RFC 5322 section | Productions | Status |
185
+ |---|---|---|
186
+ | §3.2.1 | `quoted-pair`, `obs-qp` | complete |
187
+ | §3.2.2 | `FWS`, `obs-FWS` | complete |
188
+ | §3.2.3 | `CFWS`, `comment`, `ccontent`, `ctext`, `obs-ctext`, `atom`, `dot-atom`, `dot-atom-text` | complete |
189
+ | §3.2.4 | `quoted-string`, `qcontent`, `qtext`, `obs-qtext` | complete |
190
+ | §3.2.5 | `word`, `phrase`, `obs-phrase` | complete |
191
+ | §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 |
192
+ | §3.4.1 | `addr-spec`, `local-part`, `domain`, `domain-literal`, `dtext`, `obs-local-part`, `obs-domain`, `obs-route`, `obs-angle-addr`, `obs-dtext` | complete |
193
+ | §2.1.1 | 998-character line limit | enforced |
194
+ | RFC 5321 §4.5.3.1 | 64-char local part, 255-char domain | enforced |
195
+ | RFC 1035 §2.3.4 | 63-char DNS label | enforced |
196
+
197
+ A production-by-production mapping (production → section → implementation
198
+ method → tests) lives in [`compliance.md`](compliance.md).
199
+
200
+ ## How this differs from `email.utils`
201
+
202
+ - **It rejects instead of guessing.** `parseaddr` returns `('', '')` for many
203
+ malformed inputs and never raises; `rfc5322` raises `AddressSyntaxError` with
204
+ the byte offset, or returns `False` from `is_valid_address`.
205
+ - **It preserves information the stdlib discards.** Comments are decoded into
206
+ `Address.comments` rather than leaked into the display name; domain literals
207
+ are kept (`[192.0.2.1]`) instead of being reduced to an empty string.
208
+ - **It distinguishes modern from obsolete syntax.** Obsolete §4.4 forms are
209
+ accepted only under `strict=False`, and every such parse sets
210
+ `Address.obsolete = True`, so callers can audit legacy mail.
211
+ - **It validates lists.** `parse_address_list` / `parse_mailbox_list` handle
212
+ §3.4 comma lists (including the `obs-*-list` forms), which `parseaddr` cannot
213
+ represent at all.
214
+ - **It enforces length limits** from RFC 5322 §2.1.1, RFC 5321 and RFC 1035,
215
+ which the stdlib does not check.
216
+
217
+ ## Limitations
218
+
219
+ Honest list — this parses *addresses*, not messages:
220
+
221
+ - **No MIME/header parsing.** §3.6 fields (`Received`, `Date`, `Message-ID`,
222
+ `fields`/`trace`/`optional-field`) are out of scope.
223
+ - **No message body or MIME multipart parsing.**
224
+ - **No §4.5–§4.7 obsolete message syntax** (`obs-date`, `obs-received`,
225
+ `obs-message-id`). Only the §4.4 addressing productions are implemented.
226
+ - **No semantic/DNS validation.** No MX lookup, no existence check — a
227
+ syntactically valid domain need not exist.
228
+ - **Domain-literal contents are not interpreted.** `[IPv6:...]` is validated as
229
+ `dtext` only; IPv4/IPv6 well-formedness is **not** checked. That belongs to a
230
+ network layer.
231
+ - **ASCII only. No IDN/IDNA and no SMTPUTF8 (RFC 6531).** Non-ASCII input is
232
+ rejected, because the RFC 5322 grammar is ASCII-only.
233
+ - **Length limits are stricter than the raw ABNF.** The 998/64/255/63 limits
234
+ are an extra semantic pass; the pure grammar alone would accept longer input.
235
+ - **`group` `normalized` output is canonical, not byte-identical** to the input
236
+ (display names are decoded but not re-quoted).
237
+
238
+ ## Differential testing
239
+
240
+ The comparison table above is hand-written, so it is exactly the kind of
241
+ evidence that collapses when probed. `fuzz/` is a **seeded differential
242
+ harness** that checks the claim mechanically against three references:
243
+
244
+ | Reference | What it is | DNS? |
245
+ |---|---|---|
246
+ | `email.utils.parseaddr` | the stdlib scanner the claim is about | no |
247
+ | `email.headerregistry.Address(addr_spec=…)` | CPython's strict addr-spec parser | no |
248
+ | `email_validator` | the widely-used third-party validator | **disabled** |
249
+
250
+ `email_validator` performs DNS/MX lookups by default; the harness passes
251
+ `check_deliverability=False` so the comparison is **syntax only** and never
252
+ touches the network. `email_validator` is a **dev/test-only** dependency
253
+ (`uv run --with email-validator`, or the `[fuzz]` extra) — the package itself
254
+ still has zero runtime dependencies.
255
+
256
+ The corpus is **4160 inputs** (160 hand-written + 4000 byte-level mutants),
257
+ generated with `random.Random(5322)`. Re-run it deterministically with:
258
+
259
+ ```sh
260
+ uv run --with email-validator python -m fuzz.run --seed 5322 --mutants 4000
261
+ uv run --with email-validator python -m fuzz.run --replay '<input>' # reproduce one failure
262
+ ```
263
+
264
+ Measured results (seed 5322, CPython 3.12):
265
+
266
+ | Reference | agree | we reject / it accepts | we accept / it rejects | both accept, output differs |
267
+ |---|---:|---:|---:|---:|
268
+ | `email.utils.parseaddr` | 2554 | **1360** | **105** | 141 |
269
+ | `email.headerregistry.Address` | 3727 | 177 | 196 | 60 |
270
+ | `email_validator` | 3325 | 70 | 754 | 11 |
271
+
272
+ **What the numbers mean.** They are not a scoreboard. Against `parseaddr` the
273
+ harness confirms the central claim: it accepts 1360 RFC-invalid inputs the
274
+ parser rejects (`a..b@example.com`, `.user@example.com`, `a b@example.com`,
275
+ `@example.com`, `"unbalanced@x.com`, …) and returns `('', '')` for 105 valid
276
+ ones it cannot represent (domain literals `user@[192.0.2.1]`, CFWS comments,
277
+ groups). Against `headerregistry` the parser agrees on 89.6% and the residual
278
+ divergences are its scope limits (it rejects groups, comments and bare
279
+ display names) plus length/ASCII policy. Against `email_validator` the parser
280
+ agrees on 79.9%; **the 754 "we accept / it rejects" cases are not parser
281
+ bugs** — `email_validator` deliberately rejects domain literals (§3.4.1),
282
+ CFWS comments (§3.2.3), quoted local parts and single-label domains, and it
283
+ applies IDNA/deliverability semantics that RFC 5322 does not.
284
+
285
+ **Bugs the harness found (fixed).** Adversarial fuzzing found three real
286
+ defects, all in the same family — inputs accepted only under `strict=False`
287
+ that did **not** set `Address.obsolete`, violating the documented invariant:
288
+
289
+ 1. `user. name@x.com` (obs-local-part, §4.4) parsed with `obsolete=False`.
290
+ 2. `john@example\r\n .com` (obs-domain, §4.4) parsed with `obsolete=False`.
291
+ 3. `Group: a@b.com,;` (obs-mbox-list, §3.4) parsed with `obsolete=False`, and
292
+ `parse_mailbox_list` did not implement `obs-mbox-list` at all despite the
293
+ coverage table claiming it complete.
294
+
295
+ Each has a regression test in `tests/test_obsolete.py`. The pinned counts are
296
+ asserted in `tests/test_differential.py`, so a future change that shifts them
297
+ turns CI red rather than being silently absorbed.
298
+
299
+ ## Development
300
+
301
+ ```sh
302
+ uv venv
303
+ uv pip install -e ".[dev]"
304
+ .venv/bin/python -m pytest --cov=rfc5322 --cov-report=term-missing
305
+ .venv/bin/ruff check .
306
+ ```
307
+
308
+ Current status: **288 tests passing, 99% statement coverage, ruff clean.**
309
+ See [`PROOF.txt`](PROOF.txt) for the verbatim run and [`CONTRIBUTING.md`](CONTRIBUTING.md)
310
+ before opening a PR.
311
+
312
+ ## License
313
+
314
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,280 @@
1
+ # rfc5322
2
+
3
+ [![CI](https://github.com/beduldul/rfc5322/actions/workflows/ci.yml/badge.svg)](https://github.com/beduldul/rfc5322/actions/workflows/ci.yml)
4
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
+ [![Coverage: 99%](https://img.shields.io/badge/coverage-99%25-brightgreen.svg)](https://github.com/beduldul/rfc5322)
7
+ [![Ruff](https://img.shields.io/badge/lint-ruff-clean-brightgreen.svg)](https://github.com/astral-sh/ruff)
8
+
9
+ **Python's stdlib `email.utils.parseaddr` is not RFC 5322 conformant — it silently
10
+ accepts invalid addresses and mangles valid ones. `rfc5322` is a compliant,
11
+ dependency-free, spec-tested alternative.**
12
+
13
+ A hand-written recursive-descent parser for the RFC 5322 `address` grammar
14
+ (§3.2–§3.4) **plus every obsolete §4.4 production**. Zero runtime dependencies,
15
+ stdlib only, no regexes used for grammar recognition.
16
+
17
+ **Status:** v1.0.0 — 288 tests passing, 99% coverage, CI green on Python 3.12 and
18
+ 3.13, MIT licensed. Not yet on PyPI (install from GitHub, below).
19
+
20
+ ```python
21
+ >>> from rfc5322 import is_valid_address
22
+ >>> is_valid_address("a..b@example.com") # stdlib says this is fine
23
+ False
24
+ ```
25
+
26
+ ## Why this exists
27
+
28
+ `email.utils.parseaddr` is a pragmatic header-scanner, not a grammar. It is
29
+ lenient where the RFC is strict, and lossy where the RFC is precise. Every row
30
+ below was executed against CPython 3.12:
31
+
32
+ | Input | `email.utils.parseaddr` | `rfc5322` |
33
+ |---|---|---|
34
+ | `a..b@example.com` | `('', 'a..b@example.com')` — accepts a forbidden empty atom | rejected: `expected '@' in addr-spec (offset 1)` |
35
+ | `user@[192.0.2.1]` | `('', '')` — **mangles** a valid domain literal | accepted, `domain == '[192.0.2.1]'` |
36
+ | `a@b@c.com` | `('', '')` — returns nothing, no error | rejected: `unexpected trailing input '@c.com' (offset 3)` |
37
+ | `a@example.com, b@example.com` | `('', '')` — a list is not one address | rejected; use `parse_address_list` |
38
+ | `(comment)john@example.com` | `('comment', 'john@example.com')` — leaks the comment into the display name | accepted, `comments == ('comment',)`, `display_name is None` |
39
+ | `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` |
40
+
41
+ The stdlib's contract is "best effort"; this package's contract is "the ABNF,
42
+ or an error with the byte offset". Divergences are locked down by the
43
+ `TestStdlibDivergence` test class.
44
+
45
+ ## Install
46
+
47
+ **Not yet on PyPI — install from GitHub for now.**
48
+
49
+ ```sh
50
+ # From GitHub (latest main):
51
+ pip install "git+https://github.com/beduldul/rfc5322.git"
52
+ # or with uv:
53
+ uv pip install "git+https://github.com/beduldul/rfc5322.git"
54
+
55
+ # From a clone (editable, with dev tools):
56
+ git clone https://github.com/beduldul/rfc5322.git
57
+ cd rfc5322
58
+ uv venv && uv pip install -e ".[dev]"
59
+ ```
60
+
61
+ Requires Python 3.12+. No runtime dependencies.
62
+
63
+ ## Usage
64
+
65
+ ```python
66
+ from rfc5322 import (
67
+ parse_address, is_valid_address, parse_address_list, parse_mailbox_list,
68
+ AddressSyntaxError,
69
+ )
70
+
71
+ # 1. A plain valid address, with a display name and CFWS comments.
72
+ addr = parse_address("John Doe (boss) <john.doe@example.com>")
73
+ addr.local_part # 'john.doe'
74
+ addr.domain # 'example.com'
75
+ addr.display_name # 'John Doe'
76
+ addr.comments # ('boss',)
77
+ addr.normalized # 'john.doe@example.com'
78
+
79
+ # 2. A quoted local part — quotes are stripped, quoted-pairs decoded.
80
+ parse_address('"john doe"@example.com').local_part # 'john doe'
81
+ parse_address('"a\\"b"@example.com').local_part # 'a"b'
82
+
83
+ # 3. Obsolete §4.4 syntax is rejected by default, opt in explicitly.
84
+ is_valid_address('user."quoted"@example.com') # False
85
+ obs = parse_address('user."quoted"@example.com', strict=False)
86
+ obs.local_part, obs.obsolete # ('user.quoted', True)
87
+
88
+ # 4. Invalid input raises with the exact offset and a caret excerpt.
89
+ try:
90
+ parse_address("a..b@example.com")
91
+ except AddressSyntaxError as exc:
92
+ print(exc)
93
+ # expected '@' in addr-spec (offset 1)
94
+ # a..b@example.com
95
+ # ^
96
+ ```
97
+
98
+ ### CLI
99
+
100
+ ```sh
101
+ $ python -m rfc5322 'John Doe <john@example.com>'
102
+ VALID: local_part='john' domain='example.com'
103
+ normalized=john@example.com
104
+ display_name='John Doe'
105
+
106
+ $ python -m rfc5322 --permissive 'user."q"@x.com'
107
+ VALID: local_part='user.q' domain='x.com'
108
+ normalized=user.q@x.com
109
+ (used obsolete §4.4 syntax)
110
+
111
+ $ python -m rfc5322 'a..b@x.com'; echo "exit=$?"
112
+ INVALID: expected '@' in addr-spec (offset 1)
113
+ a..b@x.com
114
+ ^
115
+ exit=1
116
+ ```
117
+
118
+ Exit status: `0` valid, `1` invalid, `2` usage error. `-p` / `--permissive`
119
+ enables the obsolete productions. Installing the package also provides a
120
+ `rfc5322` console script with the same behaviour.
121
+
122
+ ## API
123
+
124
+ | Function | Purpose |
125
+ |---|---|
126
+ | `parse_address(text, *, strict=True) -> Address` | Parse one `address` (mailbox or group). Raises `AddressSyntaxError`. |
127
+ | `is_valid_address(text, *, strict=True) -> bool` | Non-raising predicate. |
128
+ | `parse_address_list(text, *, strict=True) -> tuple[Address, ...]` | Parse an `address-list`. |
129
+ | `parse_mailbox_list(text, *, strict=True) -> tuple[Address, ...]` | Parse a `mailbox-list` (groups rejected). |
130
+
131
+ `Address` is a frozen, slotted dataclass — inputs are never mutated, every
132
+ function returns new immutable objects.
133
+
134
+ | `Address` field | Meaning |
135
+ |---|---|
136
+ | `local_part` | Decoded local part (quotes removed, `quoted-pair` decoded). |
137
+ | `domain` | Domain; a domain literal keeps its `[` `]` brackets. |
138
+ | `display_name` | Decoded `phrase` for `name-addr`/`group`, else `None`. |
139
+ | `comments` | Every CFWS comment, decoded, in source order. |
140
+ | `source` | The original input string. |
141
+ | `is_group` / `group_members` | Group flag and member addresses. |
142
+ | `obsolete` | `True` if a §4.4 production was required to accept the input. |
143
+ | `normalized` | Canonical `local@domain`, or `name:members;` for a group. |
144
+
145
+ `AddressSyntaxError` is a `ValueError` subclass carrying `position` and a
146
+ caret-annotated excerpt of the offending input.
147
+
148
+ ## RFC coverage
149
+
150
+ | RFC 5322 section | Productions | Status |
151
+ |---|---|---|
152
+ | §3.2.1 | `quoted-pair`, `obs-qp` | complete |
153
+ | §3.2.2 | `FWS`, `obs-FWS` | complete |
154
+ | §3.2.3 | `CFWS`, `comment`, `ccontent`, `ctext`, `obs-ctext`, `atom`, `dot-atom`, `dot-atom-text` | complete |
155
+ | §3.2.4 | `quoted-string`, `qcontent`, `qtext`, `obs-qtext` | complete |
156
+ | §3.2.5 | `word`, `phrase`, `obs-phrase` | complete |
157
+ | §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 |
158
+ | §3.4.1 | `addr-spec`, `local-part`, `domain`, `domain-literal`, `dtext`, `obs-local-part`, `obs-domain`, `obs-route`, `obs-angle-addr`, `obs-dtext` | complete |
159
+ | §2.1.1 | 998-character line limit | enforced |
160
+ | RFC 5321 §4.5.3.1 | 64-char local part, 255-char domain | enforced |
161
+ | RFC 1035 §2.3.4 | 63-char DNS label | enforced |
162
+
163
+ A production-by-production mapping (production → section → implementation
164
+ method → tests) lives in [`compliance.md`](compliance.md).
165
+
166
+ ## How this differs from `email.utils`
167
+
168
+ - **It rejects instead of guessing.** `parseaddr` returns `('', '')` for many
169
+ malformed inputs and never raises; `rfc5322` raises `AddressSyntaxError` with
170
+ the byte offset, or returns `False` from `is_valid_address`.
171
+ - **It preserves information the stdlib discards.** Comments are decoded into
172
+ `Address.comments` rather than leaked into the display name; domain literals
173
+ are kept (`[192.0.2.1]`) instead of being reduced to an empty string.
174
+ - **It distinguishes modern from obsolete syntax.** Obsolete §4.4 forms are
175
+ accepted only under `strict=False`, and every such parse sets
176
+ `Address.obsolete = True`, so callers can audit legacy mail.
177
+ - **It validates lists.** `parse_address_list` / `parse_mailbox_list` handle
178
+ §3.4 comma lists (including the `obs-*-list` forms), which `parseaddr` cannot
179
+ represent at all.
180
+ - **It enforces length limits** from RFC 5322 §2.1.1, RFC 5321 and RFC 1035,
181
+ which the stdlib does not check.
182
+
183
+ ## Limitations
184
+
185
+ Honest list — this parses *addresses*, not messages:
186
+
187
+ - **No MIME/header parsing.** §3.6 fields (`Received`, `Date`, `Message-ID`,
188
+ `fields`/`trace`/`optional-field`) are out of scope.
189
+ - **No message body or MIME multipart parsing.**
190
+ - **No §4.5–§4.7 obsolete message syntax** (`obs-date`, `obs-received`,
191
+ `obs-message-id`). Only the §4.4 addressing productions are implemented.
192
+ - **No semantic/DNS validation.** No MX lookup, no existence check — a
193
+ syntactically valid domain need not exist.
194
+ - **Domain-literal contents are not interpreted.** `[IPv6:...]` is validated as
195
+ `dtext` only; IPv4/IPv6 well-formedness is **not** checked. That belongs to a
196
+ network layer.
197
+ - **ASCII only. No IDN/IDNA and no SMTPUTF8 (RFC 6531).** Non-ASCII input is
198
+ rejected, because the RFC 5322 grammar is ASCII-only.
199
+ - **Length limits are stricter than the raw ABNF.** The 998/64/255/63 limits
200
+ are an extra semantic pass; the pure grammar alone would accept longer input.
201
+ - **`group` `normalized` output is canonical, not byte-identical** to the input
202
+ (display names are decoded but not re-quoted).
203
+
204
+ ## Differential testing
205
+
206
+ The comparison table above is hand-written, so it is exactly the kind of
207
+ evidence that collapses when probed. `fuzz/` is a **seeded differential
208
+ harness** that checks the claim mechanically against three references:
209
+
210
+ | Reference | What it is | DNS? |
211
+ |---|---|---|
212
+ | `email.utils.parseaddr` | the stdlib scanner the claim is about | no |
213
+ | `email.headerregistry.Address(addr_spec=…)` | CPython's strict addr-spec parser | no |
214
+ | `email_validator` | the widely-used third-party validator | **disabled** |
215
+
216
+ `email_validator` performs DNS/MX lookups by default; the harness passes
217
+ `check_deliverability=False` so the comparison is **syntax only** and never
218
+ touches the network. `email_validator` is a **dev/test-only** dependency
219
+ (`uv run --with email-validator`, or the `[fuzz]` extra) — the package itself
220
+ still has zero runtime dependencies.
221
+
222
+ The corpus is **4160 inputs** (160 hand-written + 4000 byte-level mutants),
223
+ generated with `random.Random(5322)`. Re-run it deterministically with:
224
+
225
+ ```sh
226
+ uv run --with email-validator python -m fuzz.run --seed 5322 --mutants 4000
227
+ uv run --with email-validator python -m fuzz.run --replay '<input>' # reproduce one failure
228
+ ```
229
+
230
+ Measured results (seed 5322, CPython 3.12):
231
+
232
+ | Reference | agree | we reject / it accepts | we accept / it rejects | both accept, output differs |
233
+ |---|---:|---:|---:|---:|
234
+ | `email.utils.parseaddr` | 2554 | **1360** | **105** | 141 |
235
+ | `email.headerregistry.Address` | 3727 | 177 | 196 | 60 |
236
+ | `email_validator` | 3325 | 70 | 754 | 11 |
237
+
238
+ **What the numbers mean.** They are not a scoreboard. Against `parseaddr` the
239
+ harness confirms the central claim: it accepts 1360 RFC-invalid inputs the
240
+ parser rejects (`a..b@example.com`, `.user@example.com`, `a b@example.com`,
241
+ `@example.com`, `"unbalanced@x.com`, …) and returns `('', '')` for 105 valid
242
+ ones it cannot represent (domain literals `user@[192.0.2.1]`, CFWS comments,
243
+ groups). Against `headerregistry` the parser agrees on 89.6% and the residual
244
+ divergences are its scope limits (it rejects groups, comments and bare
245
+ display names) plus length/ASCII policy. Against `email_validator` the parser
246
+ agrees on 79.9%; **the 754 "we accept / it rejects" cases are not parser
247
+ bugs** — `email_validator` deliberately rejects domain literals (§3.4.1),
248
+ CFWS comments (§3.2.3), quoted local parts and single-label domains, and it
249
+ applies IDNA/deliverability semantics that RFC 5322 does not.
250
+
251
+ **Bugs the harness found (fixed).** Adversarial fuzzing found three real
252
+ defects, all in the same family — inputs accepted only under `strict=False`
253
+ that did **not** set `Address.obsolete`, violating the documented invariant:
254
+
255
+ 1. `user. name@x.com` (obs-local-part, §4.4) parsed with `obsolete=False`.
256
+ 2. `john@example\r\n .com` (obs-domain, §4.4) parsed with `obsolete=False`.
257
+ 3. `Group: a@b.com,;` (obs-mbox-list, §3.4) parsed with `obsolete=False`, and
258
+ `parse_mailbox_list` did not implement `obs-mbox-list` at all despite the
259
+ coverage table claiming it complete.
260
+
261
+ Each has a regression test in `tests/test_obsolete.py`. The pinned counts are
262
+ asserted in `tests/test_differential.py`, so a future change that shifts them
263
+ turns CI red rather than being silently absorbed.
264
+
265
+ ## Development
266
+
267
+ ```sh
268
+ uv venv
269
+ uv pip install -e ".[dev]"
270
+ .venv/bin/python -m pytest --cov=rfc5322 --cov-report=term-missing
271
+ .venv/bin/ruff check .
272
+ ```
273
+
274
+ Current status: **288 tests passing, 99% statement coverage, ruff clean.**
275
+ See [`PROOF.txt`](PROOF.txt) for the verbatim run and [`CONTRIBUTING.md`](CONTRIBUTING.md)
276
+ before opening a PR.
277
+
278
+ ## License
279
+
280
+ MIT — see [LICENSE](LICENSE).