rfc5322 1.0.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """RFC 5322 conformant email address parser (pure stdlib)."""
2
+
3
+ from .parser import (
4
+ Address,
5
+ AddressSyntaxError,
6
+ is_valid_address,
7
+ parse_address,
8
+ parse_address_list,
9
+ parse_mailbox_list,
10
+ )
11
+
12
+ __all__ = [
13
+ "Address",
14
+ "AddressSyntaxError",
15
+ "parse_address",
16
+ "is_valid_address",
17
+ "parse_address_list",
18
+ "parse_mailbox_list",
19
+ ]
20
+
21
+ __version__ = "1.0.0"
rfc5322/__main__.py ADDED
@@ -0,0 +1,46 @@
1
+ """CLI: ``python -m rfc5322 "<address>"``.
2
+
3
+ Exit status 0 on a valid address, 1 on invalid, 2 on usage error.
4
+ ``--permissive`` enables §4.4 obsolete productions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ from .parser import AddressSyntaxError, parse_address
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ args = list(sys.argv[1:] if argv is None else argv)
16
+ strict = True
17
+ if args and args[0] in ("-p", "--permissive"):
18
+ strict = False
19
+ args.pop(0)
20
+ if len(args) != 1:
21
+ print("usage: python -m rfc5322 [--permissive] <address>", file=sys.stderr)
22
+ return 2
23
+ raw = args[0]
24
+ try:
25
+ addr = parse_address(raw, strict=strict)
26
+ except AddressSyntaxError as exc:
27
+ print(f"INVALID: {exc}", file=sys.stderr)
28
+ return 1
29
+ if addr.is_group:
30
+ print(f"VALID group: display_name={addr.display_name!r}")
31
+ for m in addr.group_members:
32
+ print(f" member: {m.normalized}")
33
+ else:
34
+ print(f"VALID: local_part={addr.local_part!r} domain={addr.domain!r}")
35
+ print(f" normalized={addr.normalized}")
36
+ if addr.display_name is not None:
37
+ print(f" display_name={addr.display_name!r}")
38
+ if addr.comments:
39
+ print(f" comments={list(addr.comments)!r}")
40
+ if addr.obsolete:
41
+ print(" (used obsolete §4.4 syntax)")
42
+ return 0
43
+
44
+
45
+ if __name__ == "__main__":
46
+ raise SystemExit(main())
rfc5322/parser.py ADDED
@@ -0,0 +1,941 @@
1
+ """RFC 5322 conformant email address parser.
2
+
3
+ Implements the ``addr-spec`` / ``mailbox`` / ``group`` grammar of RFC 5322
4
+ sections 3.2 through 3.4, plus the obsolete productions of section 4.4.
5
+
6
+ The parser is a hand-written recursive-descent parser over the raw character
7
+ stream. It does **not** use :mod:`email.utils` or any regular expression for
8
+ grammar recognition; every production is recognised explicitly so that the
9
+ obsolete forms can be accepted or rejected independently.
10
+
11
+ Public API
12
+ ----------
13
+ ``parse_address(text, *, strict=True) -> Address``
14
+ Parse a single ``address`` (mailbox or group) and return an immutable
15
+ :class:`Address`.
16
+ ``is_valid_address(text, *, strict=True) -> bool``
17
+ Convenience predicate that swallows :class:`AddressSyntaxError`.
18
+ ``parse_address_list(text, *, strict=True) -> tuple[Address, ...]``
19
+ Parse an RFC 5322 ``address-list``.
20
+ ``parse_mailbox_list(text, *, strict=True) -> tuple[Address, ...]``
21
+ Parse an RFC 5322 ``mailbox-list`` (no groups).
22
+
23
+ Immutability: :class:`Address` is a frozen, slotted dataclass. No input is
24
+ ever mutated; the parser only reads ``text``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, replace
30
+ from typing import Final
31
+
32
+ __all__ = [
33
+ "Address",
34
+ "AddressSyntaxError",
35
+ "parse_address",
36
+ "is_valid_address",
37
+ "parse_address_list",
38
+ "parse_mailbox_list",
39
+ ]
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Character classes (RFC 5234 core rules + RFC 5322 terminal productions)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ # RFC 5234 §2.3
46
+ _ALPHA: Final = frozenset(
47
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
48
+ )
49
+ _DIGIT: Final = frozenset("0123456789")
50
+ _VCHAR: Final = frozenset(chr(c) for c in range(0x21, 0x7F))
51
+ _WSP: Final = frozenset(" \t")
52
+ _CRLF: Final = "\r\n"
53
+
54
+ # RFC 5322 §3.2.3
55
+ _ATEXT: Final = frozenset(
56
+ "!#$%&'*+-/=?^_`{|}~"
57
+ ) | _ALPHA | _DIGIT
58
+
59
+ # qtext = %d33 / %d35-91 / %d93-126
60
+ _QTEXT: Final = frozenset(chr(0x21)) | frozenset(
61
+ chr(c) for c in range(0x23, 0x5C)
62
+ ) | frozenset(chr(c) for c in range(0x5D, 0x7F))
63
+
64
+ # ctext = %d33-39 / %d42-91 / %d93-126
65
+ _CTEXT: Final = frozenset(chr(c) for c in range(0x21, 0x28)) | frozenset(
66
+ chr(c) for c in range(0x2A, 0x5C)
67
+ ) | frozenset(chr(c) for c in range(0x5D, 0x7F))
68
+
69
+ # dtext = %d33-90 / %d94-126 (printable, excluding "[", "]", "\")
70
+ _DTEXT: Final = frozenset(chr(c) for c in range(0x21, 0x5B)) | frozenset(
71
+ chr(c) for c in range(0x5E, 0x7F)
72
+ )
73
+
74
+ # obs-NO-WS-CTL = %d1-8 / %d11 / %d12 / %d14-31 / %d127
75
+ _OBS_NO_WS_CTL: Final = (
76
+ frozenset(chr(c) for c in range(0x01, 0x09))
77
+ | frozenset(chr(c) for c in (0x0B, 0x0C))
78
+ | frozenset(chr(c) for c in range(0x0E, 0x20))
79
+ | frozenset(chr(0x7F))
80
+ )
81
+
82
+
83
+ # RFC 5322 §2.1.1 line length limit (excluding CRLF).
84
+ MAX_LINE_LENGTH: Final = 998
85
+ # RFC 5321 §4.5.3.1.1 local-part limit.
86
+ MAX_LOCAL_PART_LENGTH: Final = 64
87
+ # RFC 1035 §2.3.4 / RFC 5321 domain label limit.
88
+ MAX_LABEL_LENGTH: Final = 63
89
+
90
+
91
+ class AddressSyntaxError(ValueError):
92
+ """Raised when ``text`` is not a valid RFC 5322 address.
93
+
94
+ Subclasses :class:`ValueError` so callers that only guard ``ValueError``
95
+ keep working, while precise callers can catch this type.
96
+ """
97
+
98
+ def __init__(self, message: str, *, position: int = 0, text: str = "") -> None:
99
+ self.position = position
100
+ self.text = text
101
+ caret = ""
102
+ if text:
103
+ caret = f"\n {text}\n {' ' * position}^"
104
+ super().__init__(f"{message} (offset {position}){caret}")
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Result model
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class Address:
114
+ """An immutable parsed RFC 5322 address.
115
+
116
+ Attributes
117
+ ----------
118
+ local_part:
119
+ Semantic local part. For a ``dot-atom`` this is the literal text; for
120
+ a ``quoted-string`` the surrounding quotes are removed, ``quoted-pair``
121
+ escapes are decoded and folding whitespace is collapsed to one space.
122
+ domain:
123
+ Semantic domain. A ``dot-atom`` domain is returned without CFWS; a
124
+ ``domain-literal`` is returned including its ``[`` ``]`` brackets.
125
+ display_name:
126
+ Decoded ``phrase`` for ``name-addr`` / ``group``, else ``None``.
127
+ comments:
128
+ Every CFWS comment encountered, in source order, decoded.
129
+ source:
130
+ The exact input string that produced this address.
131
+ is_group:
132
+ ``True`` when the address was a ``group`` production.
133
+ group_members:
134
+ Member addresses when ``is_group`` is ``True`` (empty otherwise).
135
+ obsolete:
136
+ ``True`` when any §4.4 obsolete production was used to accept input.
137
+ """
138
+
139
+ local_part: str
140
+ domain: str
141
+ display_name: str | None = None
142
+ comments: tuple[str, ...] = ()
143
+ source: str = ""
144
+ is_group: bool = False
145
+ group_members: tuple[Address, ...] = ()
146
+ obsolete: bool = False
147
+
148
+ @property
149
+ def normalized(self) -> str:
150
+ """Return a canonical string for this address.
151
+
152
+ For a mailbox this is ``local_part@domain``. For a group it is the
153
+ canonical ``display-name:member, member;`` form.
154
+ """
155
+ if self.is_group:
156
+ inner = ", ".join(m.normalized for m in self.group_members)
157
+ return f"{self.display_name or ''}:{inner};"
158
+ return f"{self.local_part}@{self.domain}"
159
+
160
+ @property
161
+ def addr_spec(self) -> str:
162
+ """Alias for :attr:`normalized` (RFC 5322 §3.4.1 terminology)."""
163
+ return self.normalized
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Parser
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ @dataclass
172
+ class _Mark:
173
+ pos: int
174
+ comments: int
175
+ obsolete: bool
176
+ cfws_events: int
177
+
178
+
179
+ class _Parser:
180
+ """Single-use recursive-descent parser over ``text``."""
181
+
182
+ def __init__(self, text: str, *, strict: bool) -> None:
183
+ self.text = text
184
+ self.n = len(text)
185
+ self.pos = 0
186
+ self.strict = strict
187
+ self.comments: list[str] = []
188
+ self.obsolete = False
189
+ self.cfws_events = 0
190
+ self.trailing_cfws = False
191
+ self._last_word_quoted = False
192
+
193
+ # -- cursor helpers -----------------------------------------------------
194
+
195
+ def _eof(self) -> bool:
196
+ return self.pos >= self.n
197
+
198
+ def _peek(self, offset: int = 0) -> str:
199
+ i = self.pos + offset
200
+ return self.text[i] if i < self.n else ""
201
+
202
+ def _mark(self) -> _Mark:
203
+ return _Mark(self.pos, len(self.comments), self.obsolete, self.cfws_events)
204
+
205
+ def _restore(self, m: _Mark) -> None:
206
+ self.pos = m.pos
207
+ del self.comments[m.comments :]
208
+ self.obsolete = m.obsolete
209
+ self.cfws_events = m.cfws_events
210
+
211
+ def _fail(self, message: str) -> AddressSyntaxError:
212
+ return AddressSyntaxError(message, position=self.pos, text=self.text)
213
+
214
+ def _expect(self, literal: str) -> None:
215
+ if self.text.startswith(literal, self.pos):
216
+ self.pos += len(literal)
217
+ else:
218
+ raise self._fail(f"expected {literal!r}")
219
+
220
+ def _note_obsolete(self, what: str) -> None:
221
+ if self.strict:
222
+ raise self._fail(f"obsolete production {what} rejected in strict mode")
223
+ self.obsolete = True
224
+
225
+ # -- §3.2.2 FWS / obs-FWS ----------------------------------------------
226
+
227
+ def _parse_fws(self) -> bool:
228
+ """Consume ``FWS`` (or ``obs-FWS`` when non-strict). Returns consumed."""
229
+ start = self.pos
230
+ while True:
231
+ w = 0
232
+ while self._peek() in _WSP:
233
+ self.pos += 1
234
+ w += 1
235
+ if self.text.startswith(_CRLF, self.pos):
236
+ after = self.pos + 2
237
+ if after < self.n and self.text[after] in _WSP:
238
+ if w == 0 and not self.strict:
239
+ # obs-FWS allows WSP before CRLF; strict FWS requires
240
+ # at least one WSP *after* the CRLF, which we check next.
241
+ pass
242
+ self.pos = after
243
+ continue
244
+ # CRLF not followed by WSP is not a fold; leave it.
245
+ break
246
+ break
247
+ return self.pos != start
248
+
249
+ def _at_fold(self) -> bool:
250
+ """True if a CRLF folding sequence starts at the cursor."""
251
+ return self.text.startswith(_CRLF, self.pos) and (
252
+ self.pos + 2 < self.n and self.text[self.pos + 2] in _WSP
253
+ )
254
+
255
+ # -- §3.2.3 CFWS / comments --------------------------------------------
256
+
257
+ def _parse_comment(self) -> str:
258
+ """Parse one ``comment``; return its decoded content."""
259
+ self._expect("(")
260
+ parts: list[str] = []
261
+ while True:
262
+ if self._eof():
263
+ raise self._fail("unterminated comment")
264
+ ch = self._peek()
265
+ if ch == ")":
266
+ self.pos += 1
267
+ break
268
+ if ch == "(":
269
+ parts.append(self._parse_comment())
270
+ continue
271
+ if ch == "\\":
272
+ parts.append(self._parse_quoted_pair())
273
+ continue
274
+ if ch in _WSP or self._at_fold():
275
+ if self._parse_fws():
276
+ parts.append(" ")
277
+ continue
278
+ if ch in _CTEXT:
279
+ parts.append(ch)
280
+ self.pos += 1
281
+ continue
282
+ if ch in _OBS_NO_WS_CTL:
283
+ self._note_obsolete("obs-ctext")
284
+ parts.append(ch)
285
+ self.pos += 1
286
+ continue
287
+ raise self._fail("illegal character in comment")
288
+ return "".join(parts)
289
+
290
+ def _parse_cfws(self) -> bool:
291
+ """Consume ``CFWS`` (zero or more folds/comments). Returns consumed."""
292
+ consumed = False
293
+ while True:
294
+ progressed = False
295
+ if self._parse_fws():
296
+ progressed = consumed = True
297
+ if self._peek() == "(":
298
+ self.comments.append(self._parse_comment())
299
+ progressed = consumed = True
300
+ if not progressed:
301
+ break
302
+ if consumed:
303
+ self.cfws_events += 1
304
+ return consumed
305
+
306
+ # -- §3.2.1 quoted-pair -------------------------------------------------
307
+
308
+ def _parse_quoted_pair(self) -> str:
309
+ """Parse ``"\\" (VCHAR / WSP)`` or obs-qp; return the escaped char."""
310
+ if self._peek() != "\\":
311
+ raise self._fail("expected backslash")
312
+ self.pos += 1
313
+ if self._eof():
314
+ raise self._fail("trailing backslash")
315
+ ch = self._peek()
316
+ if ch in _VCHAR or ch in _WSP:
317
+ self.pos += 1
318
+ return ch
319
+ if ch in _OBS_NO_WS_CTL or ch in ("\r", "\n", "\x00"):
320
+ self._note_obsolete("obs-qp")
321
+ self.pos += 1
322
+ return ch
323
+ raise self._fail("illegal character after backslash")
324
+
325
+ # -- §3.2.4 quoted-string ----------------------------------------------
326
+
327
+ def _parse_quoted_string(self) -> str:
328
+ """Parse ``quoted-string``; return decoded content without quotes."""
329
+ self._parse_cfws()
330
+ self._expect('"')
331
+ parts: list[str] = []
332
+ while True:
333
+ if self._eof():
334
+ raise self._fail("unterminated quoted-string")
335
+ ch = self._peek()
336
+ if ch == '"':
337
+ self.pos += 1
338
+ break
339
+ if ch == "\\":
340
+ parts.append(self._parse_quoted_pair())
341
+ continue
342
+ if ch in _WSP or self._at_fold():
343
+ if self._parse_fws():
344
+ parts.append(" ")
345
+ continue
346
+ if ch in _QTEXT:
347
+ parts.append(ch)
348
+ self.pos += 1
349
+ continue
350
+ if ch in _OBS_NO_WS_CTL:
351
+ self._note_obsolete("obs-qtext")
352
+ parts.append(ch)
353
+ self.pos += 1
354
+ continue
355
+ raise self._fail("illegal character in quoted-string")
356
+ self.trailing_cfws = self._parse_cfws()
357
+ return "".join(parts)
358
+
359
+ # -- §3.2.3 atoms -------------------------------------------------------
360
+
361
+ def _parse_atom(self) -> str:
362
+ """Parse ``atom = [CFWS] 1*atext [CFWS]``."""
363
+ self._parse_cfws()
364
+ start = self.pos
365
+ while self._peek() in _ATEXT:
366
+ self.pos += 1
367
+ if self.pos == start:
368
+ raise self._fail("expected atext")
369
+ text = self.text[start : self.pos]
370
+ self.trailing_cfws = self._parse_cfws()
371
+ return text
372
+
373
+ def _parse_dot_atom_text(self) -> str:
374
+ """Parse ``dot-atom-text = 1*atext *("." 1*atext)`` (no CFWS)."""
375
+ start = self.pos
376
+ while self._peek() in _ATEXT:
377
+ self.pos += 1
378
+ if self.pos == start:
379
+ raise self._fail("expected atext")
380
+ while self._peek() == ".":
381
+ dot = self.pos
382
+ self.pos += 1
383
+ seg = self.pos
384
+ while self._peek() in _ATEXT:
385
+ self.pos += 1
386
+ if self.pos == seg:
387
+ self.pos = dot
388
+ break
389
+ return self.text[start : self.pos]
390
+
391
+ def _parse_dot_atom(self) -> str:
392
+ """Parse ``dot-atom = [CFWS] dot-atom-text [CFWS]``."""
393
+ self._parse_cfws()
394
+ text = self._parse_dot_atom_text()
395
+ self._parse_cfws()
396
+ return text
397
+
398
+ # -- §3.4.1 local-part / domain ----------------------------------------
399
+
400
+ def _parse_local_part(self) -> str:
401
+ """Parse ``local-part`` honouring strict/obsolete rules."""
402
+ if not self.strict:
403
+ # obs-local-part = word *("." word)
404
+ self._parse_cfws()
405
+ words: list[str] = []
406
+ quoted = 0
407
+ word = self._parse_word()
408
+ if self._last_word_quoted:
409
+ quoted += 1
410
+ words.append(word)
411
+ while True:
412
+ m = self._mark()
413
+ self._parse_cfws()
414
+ if self._peek() == ".":
415
+ self.pos += 1
416
+ try:
417
+ word = self._parse_word()
418
+ except AddressSyntaxError:
419
+ self._restore(m)
420
+ break
421
+ if self._last_word_quoted:
422
+ quoted += 1
423
+ words.append(word)
424
+ continue
425
+ self._restore(m)
426
+ break
427
+ # A lone quoted-string is the modern form; mixing words and dots
428
+ # (or multiple quoted strings) is the §4.4 obsolete production.
429
+ if quoted and (len(words) > 1 or quoted > 1):
430
+ self.obsolete = True
431
+ return ".".join(words)
432
+ if self._peek() == '"' or self._looks_like_quoted_string():
433
+ return self._parse_quoted_string()
434
+ return self._parse_dot_atom()
435
+
436
+ def _looks_like_quoted_string(self) -> bool:
437
+ m = self._mark()
438
+ try:
439
+ self._parse_cfws()
440
+ return self._peek() == '"'
441
+ finally:
442
+ self._restore(m)
443
+
444
+ def _parse_word(self) -> str:
445
+ """``word = atom / quoted-string``."""
446
+ if self._looks_like_quoted_string():
447
+ self._last_word_quoted = True
448
+ return self._parse_quoted_string()
449
+ self._last_word_quoted = False
450
+ return self._parse_atom()
451
+
452
+ def _parse_domain_literal(self) -> str:
453
+ """Parse ``domain-literal``; returns text including brackets."""
454
+ self._parse_cfws()
455
+ self._expect("[")
456
+ parts: list[str] = []
457
+ while True:
458
+ if self._eof():
459
+ raise self._fail("unterminated domain-literal")
460
+ ch = self._peek()
461
+ if ch == "]":
462
+ self.pos += 1
463
+ break
464
+ if ch == "\\":
465
+ if self.strict:
466
+ raise self._fail("quoted-pair in dtext is obs-dtext (strict)")
467
+ self._note_obsolete("obs-dtext")
468
+ parts.append(self._parse_quoted_pair())
469
+ continue
470
+ if ch in _WSP or self._at_fold():
471
+ if self._parse_fws():
472
+ parts.append(" ")
473
+ continue
474
+ if ch in _DTEXT:
475
+ parts.append(ch)
476
+ self.pos += 1
477
+ continue
478
+ if ch in _OBS_NO_WS_CTL:
479
+ self._note_obsolete("obs-dtext")
480
+ parts.append(ch)
481
+ self.pos += 1
482
+ continue
483
+ raise self._fail("illegal character in domain-literal")
484
+ self._parse_cfws()
485
+ return "[" + "".join(parts) + "]"
486
+
487
+ def _parse_domain(self) -> str:
488
+ """Parse ``domain`` honouring strict/obsolete rules."""
489
+ m = self._mark()
490
+ self._parse_cfws()
491
+ if self._peek() == "[":
492
+ self._restore(m)
493
+ return self._parse_domain_literal()
494
+ self._restore(m)
495
+ if not self.strict:
496
+ # obs-domain = atom *("." atom)
497
+ parts = [self._parse_atom()]
498
+ while True:
499
+ m = self._mark()
500
+ self._parse_cfws()
501
+ if self._peek() == ".":
502
+ self.pos += 1
503
+ try:
504
+ parts.append(self._parse_atom())
505
+ continue
506
+ except AddressSyntaxError:
507
+ self._restore(m)
508
+ break
509
+ self._restore(m)
510
+ break
511
+ return ".".join(parts)
512
+ return self._parse_dot_atom()
513
+
514
+ def _parse_addr_spec(self) -> tuple[str, str]:
515
+ """Parse ``addr-spec = local-part "@" domain``."""
516
+ local = self._parse_local_part()
517
+ m = self._mark()
518
+ self._parse_cfws()
519
+ if self._peek() != "@":
520
+ self._restore(m)
521
+ raise self._fail("expected '@' in addr-spec")
522
+ self.pos += 1
523
+ self._restore(m)
524
+ # consume local-part trailing CFWS + '@' + leading CFWS of domain
525
+ self._parse_cfws()
526
+ self._expect("@")
527
+ domain = self._parse_domain()
528
+ return local, domain
529
+
530
+ # -- §3.2.5 phrase ------------------------------------------------------
531
+
532
+ def _parse_phrase(self) -> str:
533
+ """Parse ``phrase = 1*word`` (obs-phrase when non-strict).
534
+
535
+ Words are separated by CFWS; a single space is emitted for any CFWS
536
+ boundary so that ``A Group`` (or a comment between words) yields the
537
+ display name ``A Group`` rather than ``AGroup``.
538
+ """
539
+ m0 = self._mark()
540
+ words: list[str] = [self._parse_word()]
541
+ while True:
542
+ m = self._mark()
543
+ had_ws = self.trailing_cfws
544
+ self._parse_cfws()
545
+ if self._peek() == '"' or self._peek() in _ATEXT:
546
+ if had_ws:
547
+ words.append(" ")
548
+ words.append(self._parse_word())
549
+ continue
550
+ if not self.strict and self._peek() == ".":
551
+ self.pos += 1
552
+ self.obsolete = True
553
+ words.append(".")
554
+ self.trailing_cfws = True
555
+ continue
556
+ self._restore(m)
557
+ break
558
+ text = "".join(words).strip()
559
+ if not text:
560
+ self._restore(m0)
561
+ raise self._fail("expected phrase")
562
+ return text
563
+
564
+ # -- §3.4.1 angle-addr / obs-route -------------------------------------
565
+
566
+ def _parse_angle_addr(self) -> tuple[str, str]:
567
+ """Parse ``angle-addr`` / ``obs-angle-addr``; return (local, domain)."""
568
+ self._parse_cfws()
569
+ self._expect("<")
570
+ self._parse_cfws()
571
+ if self._peek() == "@":
572
+ # obs-angle-addr = [CFWS] "<" obs-route addr-spec ">" [CFWS]
573
+ self._note_obsolete("obs-angle-addr / obs-route")
574
+ while True:
575
+ self._expect("@")
576
+ self._parse_domain()
577
+ self._parse_cfws()
578
+ if self._peek() == ",":
579
+ self.pos += 1
580
+ self._parse_cfws()
581
+ continue
582
+ break
583
+ self._expect(":")
584
+ self._parse_cfws()
585
+ local, domain = self._parse_addr_spec()
586
+ self._parse_cfws()
587
+ self._expect(">")
588
+ self._parse_cfws()
589
+ return local, domain
590
+
591
+ # -- §3.4 mailbox / group ----------------------------------------------
592
+
593
+ def _parse_mailbox(self, source: str) -> Address:
594
+ """Parse ``mailbox`` (``name-addr`` or bare ``addr-spec``)."""
595
+ if self._peek() == "<" or self._looks_like_angle_addr():
596
+ local, domain = self._parse_angle_addr()
597
+ display = self._pending_display
598
+ self._pending_display = None
599
+ return self._make(source, local, domain, display)
600
+ m = self._mark()
601
+ try:
602
+ display = self._parse_phrase()
603
+ except AddressSyntaxError:
604
+ self._restore(m)
605
+ local, domain = self._parse_addr_spec()
606
+ return self._make(source, local, domain, None)
607
+ m2 = self._mark()
608
+ self._parse_cfws()
609
+ if self._peek() == "<":
610
+ self._restore(m2)
611
+ self._pending_display = display
612
+ local, domain = self._parse_angle_addr()
613
+ self._pending_display = None
614
+ return self._make(source, local, domain, display)
615
+ self._restore(m)
616
+ local, domain = self._parse_addr_spec()
617
+ return self._make(source, local, domain, None)
618
+
619
+ def _looks_like_angle_addr(self) -> bool:
620
+ m = self._mark()
621
+ try:
622
+ self._parse_cfws()
623
+ return self._peek() == "<"
624
+ finally:
625
+ self._restore(m)
626
+
627
+ def _parse_group(self, source: str) -> Address | None:
628
+ """Parse ``group`` or return ``None`` if the input is not a group."""
629
+ m = self._mark()
630
+ try:
631
+ display = self._parse_phrase()
632
+ except AddressSyntaxError:
633
+ self._restore(m)
634
+ return None
635
+ self._parse_cfws()
636
+ if self._peek() != ":":
637
+ self._restore(m)
638
+ return None
639
+ self.pos += 1
640
+ members: list[Address] = []
641
+ self._parse_cfws()
642
+ if self._peek() == ";":
643
+ self.pos += 1
644
+ self._parse_cfws()
645
+ return self._make_group(source, display, members)
646
+ # group-list = mailbox-list / CFWS / obs-group-list
647
+ if self.strict:
648
+ if self._peek() == ",":
649
+ self._restore(m)
650
+ return None
651
+ else:
652
+ # obs-group-list = 1*([CFWS] ",") [CFWS]
653
+ while True:
654
+ mm = self._mark()
655
+ self._parse_cfws()
656
+ if self._peek() == ",":
657
+ self.pos += 1
658
+ self.obsolete = True
659
+ self._parse_cfws()
660
+ continue
661
+ self._restore(mm)
662
+ break
663
+ if self._peek() == ";":
664
+ self.pos += 1
665
+ self._parse_cfws()
666
+ return self._make_group(source, display, members)
667
+ while True:
668
+ self._parse_cfws()
669
+ if self._peek() == ";":
670
+ self.pos += 1
671
+ self._parse_cfws()
672
+ break
673
+ if self._eof():
674
+ self._restore(m)
675
+ return None
676
+ member = self._parse_mailbox(self.text)
677
+ members.append(member)
678
+ self._parse_cfws()
679
+ if self._peek() == ",":
680
+ self.pos += 1
681
+ self._parse_cfws()
682
+ if self._peek() == ";" and self.strict:
683
+ # group-list = mailbox-list / CFWS / obs-group-list; a
684
+ # trailing comma before ";" is not part of any of these.
685
+ raise self._fail("trailing comma in group-list")
686
+ continue
687
+ if self._peek() == ";":
688
+ self.pos += 1
689
+ self._parse_cfws()
690
+ break
691
+ self._restore(m)
692
+ return None
693
+ return self._make_group(source, display, members)
694
+
695
+ # -- builders -----------------------------------------------------------
696
+
697
+ _pending_display: str | None = None
698
+
699
+ def _make(
700
+ self, source: str, local: str, domain: str, display: str | None
701
+ ) -> Address:
702
+ return Address(
703
+ local_part=local,
704
+ domain=domain,
705
+ display_name=display,
706
+ comments=tuple(self.comments),
707
+ source=source,
708
+ obsolete=self.obsolete,
709
+ )
710
+
711
+ def _make_group(
712
+ self, source: str, display: str, members: list[Address]
713
+ ) -> Address:
714
+ return Address(
715
+ local_part="",
716
+ domain="",
717
+ display_name=display,
718
+ comments=tuple(self.comments),
719
+ source=source,
720
+ is_group=True,
721
+ group_members=tuple(members),
722
+ obsolete=self.obsolete,
723
+ )
724
+
725
+
726
+ # ---------------------------------------------------------------------------
727
+ # Public API
728
+ # ---------------------------------------------------------------------------
729
+
730
+
731
+ def _finish(p: _Parser, source: str) -> None:
732
+ p._parse_cfws()
733
+ if not p._eof():
734
+ raise p._fail(f"unexpected trailing input {source[p.pos:]!r}")
735
+
736
+
737
+ def _validate_lengths(addr: Address) -> None:
738
+ """Enforce the practical length limits an RFC 5322 address must satisfy.
739
+
740
+ RFC 5322 §2.1.1 caps a line at 998 characters; RFC 5321 §4.5.3.1.1 caps
741
+ the local part at 64 characters and the domain at 255, with each DNS label
742
+ at most 63 (RFC 1035 §2.3.4). These are the limits the originating issue
743
+ calls out explicitly.
744
+ """
745
+ if len(addr.source) > MAX_LINE_LENGTH:
746
+ raise AddressSyntaxError(
747
+ f"address exceeds {MAX_LINE_LENGTH} characters",
748
+ position=MAX_LINE_LENGTH,
749
+ text=addr.source,
750
+ )
751
+ if addr.is_group:
752
+ for member in addr.group_members:
753
+ _validate_lengths(member)
754
+ return
755
+ if len(addr.local_part) > MAX_LOCAL_PART_LENGTH:
756
+ raise AddressSyntaxError(
757
+ f"local-part exceeds {MAX_LOCAL_PART_LENGTH} characters",
758
+ position=MAX_LOCAL_PART_LENGTH,
759
+ text=addr.source,
760
+ )
761
+ domain = addr.domain
762
+ if domain.startswith("[") and domain.endswith("]"):
763
+ return # domain-literal: RFC 5322 places no length limit on dtext.
764
+ if len(domain) > 255:
765
+ raise AddressSyntaxError(
766
+ "domain exceeds 255 characters", position=0, text=addr.source
767
+ )
768
+ for label in domain.split("."):
769
+ if len(label) > MAX_LABEL_LENGTH:
770
+ raise AddressSyntaxError(
771
+ f"domain label exceeds {MAX_LABEL_LENGTH} characters",
772
+ position=0,
773
+ text=addr.source,
774
+ )
775
+
776
+
777
+ def _infer_obsolete(text: str, addr: Address) -> Address:
778
+ """Set ``obsolete=True`` when the input is only valid under §4.4.
779
+
780
+ ``strict=False`` enables the obsolete productions; a given input may still
781
+ be purely modern. Rather than guess which production fired, we settle the
782
+ question empirically: if the same text is *rejected* by the strict grammar
783
+ then an obsolete production was required, so the flag must be set. This
784
+ keeps the documented invariant ``accepted only under strict=False =>
785
+ Address.obsolete is True`` true for every production, including the ones
786
+ that are recognised but do not currently call :meth:`_note_obsolete`.
787
+ """
788
+ if addr.obsolete:
789
+ return addr
790
+ try:
791
+ parse_address(text, strict=True)
792
+ except AddressSyntaxError:
793
+ return replace(addr, obsolete=True)
794
+ return addr
795
+
796
+
797
+ def parse_address(text: str, *, strict: bool = True) -> Address:
798
+ """Parse a single RFC 5322 ``address`` (a mailbox or a group).
799
+
800
+ Parameters
801
+ ----------
802
+ text:
803
+ The raw address text. Must be a :class:`str`.
804
+ strict:
805
+ When ``True`` (default) the §4.4 obsolete productions are rejected.
806
+ When ``False`` they are accepted and :attr:`Address.obsolete` is set.
807
+
808
+ Raises
809
+ ------
810
+ TypeError
811
+ If ``text`` is not a :class:`str`.
812
+ AddressSyntaxError
813
+ If ``text`` is not a valid address.
814
+ """
815
+ if not isinstance(text, str):
816
+ raise TypeError(f"address must be str, got {type(text).__name__}")
817
+ if not text.strip():
818
+ raise AddressSyntaxError("empty address", position=0, text=text)
819
+ p = _Parser(text, strict=strict)
820
+ group = p._parse_group(text)
821
+ if group is not None:
822
+ _finish(p, text)
823
+ _validate_lengths(group)
824
+ return group if strict else _infer_obsolete(text, group)
825
+ p = _Parser(text, strict=strict)
826
+ addr = p._parse_mailbox(text)
827
+ _finish(p, text)
828
+ _validate_lengths(addr)
829
+ return addr if strict else _infer_obsolete(text, addr)
830
+
831
+
832
+ def is_valid_address(text: str, *, strict: bool = True) -> bool:
833
+ """Return ``True`` iff ``text`` parses as a single RFC 5322 address."""
834
+ try:
835
+ parse_address(text, strict=strict)
836
+ except (AddressSyntaxError, TypeError):
837
+ return False
838
+ return True
839
+
840
+
841
+ def parse_address_list(text: str, *, strict: bool = True) -> tuple[Address, ...]:
842
+ """Parse an RFC 5322 ``address-list`` (comma-separated addresses)."""
843
+ if not isinstance(text, str):
844
+ raise TypeError(f"address list must be str, got {type(text).__name__}")
845
+ if not text.strip():
846
+ raise AddressSyntaxError("empty address list", position=0, text=text)
847
+ p = _Parser(text, strict=strict)
848
+ out: list[Address] = []
849
+ p._parse_cfws()
850
+ # obs-addr-list = *([CFWS] ",") address *("," [address / CFWS])
851
+ if not strict:
852
+ while True:
853
+ m = p._mark()
854
+ p._parse_cfws()
855
+ if p._peek() == ",":
856
+ p.pos += 1
857
+ p.obsolete = True
858
+ continue
859
+ p._restore(m)
860
+ break
861
+ while True:
862
+ p._parse_cfws()
863
+ if p._eof():
864
+ break
865
+ group = p._parse_group(text)
866
+ if group is not None:
867
+ out.append(group)
868
+ else:
869
+ out.append(p._parse_mailbox(text))
870
+ p._parse_cfws()
871
+ if p._peek() == ",":
872
+ p.pos += 1
873
+ p._parse_cfws()
874
+ if p._eof():
875
+ if strict:
876
+ raise p._fail("trailing comma in address-list")
877
+ p.obsolete = True
878
+ break
879
+ continue
880
+ break
881
+ _finish(p, text)
882
+ if not strict and _list_requires_obsolete(text, kind="address"):
883
+ out = [replace(a, obsolete=True) for a in out]
884
+ return tuple(out)
885
+
886
+
887
+ def _list_requires_obsolete(text: str, *, kind: str) -> bool:
888
+ """Return ``True`` if ``text`` is not a valid list under the strict grammar.
889
+
890
+ Used by the permissive list parsers to decide whether §4.4 syntax was
891
+ required. ``kind`` is ``"address"`` or ``"mailbox"``. A strict parse
892
+ never recurses into this probe, so there is no infinite regress.
893
+ """
894
+ fn = parse_address_list if kind == "address" else parse_mailbox_list
895
+ try:
896
+ fn(text, strict=True)
897
+ except AddressSyntaxError:
898
+ return True
899
+ return False
900
+
901
+
902
+ def parse_mailbox_list(text: str, *, strict: bool = True) -> tuple[Address, ...]:
903
+ """Parse an RFC 5322 ``mailbox-list`` (no groups allowed)."""
904
+ if not isinstance(text, str):
905
+ raise TypeError(f"mailbox list must be str, got {type(text).__name__}")
906
+ if not text.strip():
907
+ raise AddressSyntaxError("empty mailbox list", position=0, text=text)
908
+ p = _Parser(text, strict=strict)
909
+ out: list[Address] = []
910
+ p._parse_cfws()
911
+ # obs-mbox-list = *([CFWS] ",") mailbox *("," [mailbox / CFWS])
912
+ if not strict:
913
+ while True:
914
+ m = p._mark()
915
+ p._parse_cfws()
916
+ if p._peek() == ",":
917
+ p.pos += 1
918
+ p.obsolete = True
919
+ continue
920
+ p._restore(m)
921
+ break
922
+ while True:
923
+ p._parse_cfws()
924
+ if p._eof():
925
+ break
926
+ out.append(p._parse_mailbox(text))
927
+ p._parse_cfws()
928
+ if p._peek() == ",":
929
+ p.pos += 1
930
+ p._parse_cfws()
931
+ if p._eof():
932
+ if strict:
933
+ raise p._fail("trailing comma in mailbox-list")
934
+ p.obsolete = True
935
+ break
936
+ continue
937
+ break
938
+ _finish(p, text)
939
+ if not strict and _list_requires_obsolete(text, kind="mailbox"):
940
+ out = [replace(a, obsolete=True) for a in out]
941
+ return tuple(out)
rfc5322/py.typed ADDED
File without changes
@@ -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,10 @@
1
+ rfc5322/__init__.py,sha256=5r_hQrXYBz8eeSyCLVPHCWePucf_5eiq4_rh9WLu_VM,389
2
+ rfc5322/__main__.py,sha256=XAinvAf-gl7uKCYQ3x3Y5aukmm5bVTmjnMc5xSfGcMk,1420
3
+ rfc5322/parser.py,sha256=nw7VibzxD37Hs7i2Mtp34O4SyRHJY4CPVmcznswGaC8,32715
4
+ rfc5322/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ rfc5322-1.0.0.dist-info/licenses/LICENSE,sha256=3j3LZmQJURkKIcWolKTi5A79nGQQ6peOoze9EZg6RpA,1077
6
+ rfc5322-1.0.0.dist-info/METADATA,sha256=lgEht00NkFFER85HmX8dZK7kT_lbDFUXEQYrNEdLlRc,14491
7
+ rfc5322-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ rfc5322-1.0.0.dist-info/entry_points.txt,sha256=A3zdtL7MvcZSYzloMtGL15vMyClg43NSuWm9e4WHvnE,50
9
+ rfc5322-1.0.0.dist-info/top_level.txt,sha256=6gVVazHZA9PFM-TWkzHxiWu8jjsYZHF5_BpKP88XoZs,8
10
+ rfc5322-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rfc5322 = rfc5322.__main__:main
@@ -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.
@@ -0,0 +1 @@
1
+ rfc5322