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 +21 -0
- rfc5322/__main__.py +46 -0
- rfc5322/parser.py +941 -0
- rfc5322/py.typed +0 -0
- rfc5322-1.0.0.dist-info/METADATA +314 -0
- rfc5322-1.0.0.dist-info/RECORD +10 -0
- rfc5322-1.0.0.dist-info/WHEEL +5 -0
- rfc5322-1.0.0.dist-info/entry_points.txt +2 -0
- rfc5322-1.0.0.dist-info/licenses/LICENSE +21 -0
- rfc5322-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
[](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).
|
|
@@ -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,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
|