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 +21 -0
- rfc5322-1.0.0/PKG-INFO +314 -0
- rfc5322-1.0.0/README.md +280 -0
- rfc5322-1.0.0/pyproject.toml +79 -0
- rfc5322-1.0.0/rfc5322/__init__.py +21 -0
- rfc5322-1.0.0/rfc5322/__main__.py +46 -0
- rfc5322-1.0.0/rfc5322/parser.py +941 -0
- rfc5322-1.0.0/rfc5322/py.typed +0 -0
- rfc5322-1.0.0/rfc5322.egg-info/PKG-INFO +314 -0
- rfc5322-1.0.0/rfc5322.egg-info/SOURCES.txt +17 -0
- rfc5322-1.0.0/rfc5322.egg-info/dependency_links.txt +1 -0
- rfc5322-1.0.0/rfc5322.egg-info/entry_points.txt +2 -0
- rfc5322-1.0.0/rfc5322.egg-info/requires.txt +8 -0
- rfc5322-1.0.0/rfc5322.egg-info/top_level.txt +1 -0
- rfc5322-1.0.0/setup.cfg +4 -0
- rfc5322-1.0.0/tests/test_cli.py +56 -0
- rfc5322-1.0.0/tests/test_differential.py +199 -0
- rfc5322-1.0.0/tests/test_obsolete.py +231 -0
- rfc5322-1.0.0/tests/test_parser.py +744 -0
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
|
+
[](https://github.com/beduldul/rfc5322/actions/workflows/ci.yml)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
[](https://github.com/beduldul/rfc5322)
|
|
41
|
+
[](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).
|
rfc5322-1.0.0/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# rfc5322
|
|
2
|
+
|
|
3
|
+
[](https://github.com/beduldul/rfc5322/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://github.com/beduldul/rfc5322)
|
|
7
|
+
[](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).
|