From d663ef9c83f656464f347b29efa146bc149e0211 Mon Sep 17 00:00:00 2001 From: kapookky123 Date: Thu, 30 Jul 2026 14:44:40 +0000 Subject: [PATCH] fix: [bounty $400] Implement ABNF-compliant email address parser with full Closes #1 --- __pycache__/parser.cpython-312.pyc | Bin 0 -> 33418 bytes __pycache__/test_parser.cpython-312.pyc | Bin 0 -> 36429 bytes compliance.md | 66 ++ parser.py | 865 ++++++++++++++++++++++++ source.md | 70 +- test_parser.py | 438 ++++++++++++ 6 files changed, 1435 insertions(+), 4 deletions(-) create mode 100644 __pycache__/parser.cpython-312.pyc create mode 100644 __pycache__/test_parser.cpython-312.pyc create mode 100644 compliance.md create mode 100644 parser.py create mode 100644 test_parser.py diff --git a/__pycache__/parser.cpython-312.pyc b/__pycache__/parser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..275ec2c44e336337508c828aa0be2d51cc6db761 GIT binary patch literal 33418 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 G?*9PSvAv7{ literal 0 HcmV?d00001 diff --git a/__pycache__/test_parser.cpython-312.pyc b/__pycache__/test_parser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3531943c1f19f6c462206172850a2dd83f073f5b GIT binary patch literal 36429 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 bool: + o = ord(ch) + return o == 33 or 35 <= o <= 91 or 93 <= o <= 126 + + +# ctext: printable US-ASCII excluding ( ) \ (§3.2.2) +# %d33-39 / %d42-91 / %d93-126 +def _is_ctext(ch: str) -> bool: + o = ord(ch) + return (33 <= o <= 39) or (42 <= o <= 91) or (93 <= o <= 126) + + +# dtext: printable US-ASCII excluding [ ] \ (§3.4.1) +# %d33-90 / %d94-126 +def _is_dtext(ch: str) -> bool: + o = ord(ch) + return (33 <= o <= 90) or (94 <= o <= 126) + + +# VCHAR: %x21-7E +def _is_vchar(ch: str) -> bool: + return 33 <= ord(ch) <= 126 + + +def _is_wsp(ch: str) -> bool: + return ch in (" ", "\t") + + +class ParseError(ValueError): + """Raised when input does not match RFC 5322 address syntax.""" + + def __init__(self, message: str, position: int = -1) -> None: + self.position = position + super().__init__(message if position < 0 else f"{message} at position {position}") + + +@dataclass +class RFC5322Address: + """Parsed RFC 5322 email address.""" + + display_name: Optional[str] + local_part: str + domain: str + is_group: bool + group_members: List["RFC5322Address"] + comments: List[str] + source: str + + +class AddressParser: + """ + RFC 5322 compliant email address parser. + + Implements full ABNF grammar from §3.2-§3.4 with optional + obsolete syntax support from §4.4. + """ + + MAX_LENGTH = 998 # RFC 5322 line length limit (§2.1.1) + + def __init__(self, strict: bool = True) -> None: + """ + Args: + strict: If True, reject obs-* productions. + If False, accept obsolete forms per §4.4. + """ + self.strict = strict + self._s: str = "" + self._i: int = 0 + self._comments: List[str] = [] + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def parse(self, raw: str) -> RFC5322Address: + """Parse a single mailbox or group address.""" + self._prepare(raw) + addr = self._parse_address() + self._skip_cfws() + if self._i < len(self._s): + raise ParseError("trailing garbage after address", self._i) + addr.comments = list(self._comments) + addr.source = raw + return addr + + def parse_address_list(self, raw: str) -> List[RFC5322Address]: + """Parse a comma-separated address-list per §3.4.""" + self._prepare(raw) + result = self._parse_address_list() + self._skip_cfws() + if self._i < len(self._s): + raise ParseError("trailing garbage after address-list", self._i) + return result + + def parse_mailbox_list(self, raw: str) -> List[RFC5322Address]: + """Parse a comma-separated mailbox-list per §3.4.""" + self._prepare(raw) + result = self._parse_mailbox_list() + self._skip_cfws() + if self._i < len(self._s): + raise ParseError("trailing garbage after mailbox-list", self._i) + return result + + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + + def _prepare(self, raw: str) -> None: + if not isinstance(raw, str): + raise TypeError("input must be str") + if len(raw) > self.MAX_LENGTH: + raise ParseError( + f"input exceeds RFC 5322 line length limit of {self.MAX_LENGTH} characters" + ) + self._s = raw + self._i = 0 + self._comments = [] + + def _peek(self, n: int = 0) -> Optional[str]: + pos = self._i + n + if pos < len(self._s): + return self._s[pos] + return None + + def _advance(self, n: int = 1) -> str: + ch = self._s[self._i : self._i + n] + self._i += n + return ch + + def _at_end(self) -> bool: + return self._i >= len(self._s) + + # ------------------------------------------------------------------ + # §3.2.1 quoted-pair + # ------------------------------------------------------------------ + + def _try_quoted_pair(self) -> Optional[str]: + """Parse quoted-pair = ("\\" (VCHAR / WSP)) [/ obs-qp]. Returns char or None.""" + if self._peek() != "\\": + return None + nxt = self._peek(1) + if nxt is None: + return None + if _is_vchar(nxt) or _is_wsp(nxt): + self._advance(2) + return nxt + # obs-qp allows \ + obs-NO-WS-CTL / LF / CR (§4.1) — only in permissive mode + if not self.strict: + o = ord(nxt) + if o == 0 or (1 <= o <= 8) or o == 11 or o == 12 or (14 <= o <= 31) or o == 127 or nxt in ("\n", "\r"): + self._advance(2) + return nxt + return None + + # ------------------------------------------------------------------ + # §3.2.2 FWS / comment / CFWS + # ------------------------------------------------------------------ + + def _try_fws(self) -> bool: + """ + FWS = ([*WSP CRLF] 1*WSP) / obs-FWS + Also accept plain runs of WSP (unfolded form). + Returns True if any FWS was consumed. + """ + saw = False + while self._peek() is not None and _is_wsp(self._peek()): # type: ignore[arg-type] + self._advance() + saw = True + while self._peek() == "\r" and self._peek(1) == "\n": + if self._peek(2) is not None and _is_wsp(self._peek(2)): # type: ignore[arg-type] + self._advance(2) # CRLF (semantically invisible) + while self._peek() is not None and _is_wsp(self._peek()): # type: ignore[arg-type] + self._advance() + saw = True + else: + break + return saw + + def _try_fws_collect_wsp(self) -> str: + """ + Like FWS, but return preserved WSP characters (CRLF folding removed). + Used inside quoted-string where WSP is semantic content (§3.2.4). + """ + parts: List[str] = [] + while True: + ch = self._peek() + if ch is not None and _is_wsp(ch): + parts.append(self._advance()) + continue + if self._peek() == "\r" and self._peek(1) == "\n": + if self._peek(2) is not None and _is_wsp(self._peek(2)): # type: ignore[arg-type] + self._advance(2) # drop CRLF + continue + break + return "".join(parts) + + def _parse_fws_required(self) -> None: + if not self._try_fws(): + # Allow zero-width only where optional; callers use _try + pass + + def _try_comment(self) -> Optional[str]: + """comment = '(' *([FWS] ccontent) [FWS] ')'""" + if self._peek() != "(": + return None + self._advance() # ( + parts: List[str] = [] + while True: + self._try_fws() + if self._peek() == ")": + self._advance() + text = "".join(parts) + self._comments.append(text) + return text + if self._at_end(): + raise ParseError("unclosed comment", self._i) + # ccontent = ctext / quoted-pair / comment + qp = self._try_quoted_pair() + if qp is not None: + parts.append(qp) + continue + if self._peek() == "(": + nested = self._try_comment() + if nested is not None: + parts.append("(" + nested + ")") + continue + ch = self._peek() + if ch is not None and _is_ctext(ch): + parts.append(self._advance()) + continue + # obs-ctext in permissive mode + if not self.strict and ch is not None: + o = ord(ch) + if o == 0 or (1 <= o <= 8) or o == 11 or o == 12 or (14 <= o <= 31) or o == 127: + parts.append(self._advance()) + continue + raise ParseError(f"invalid character in comment: {ch!r}", self._i) + + def _skip_cfws(self) -> bool: + """ + CFWS = (1*([FWS] comment) [FWS]) / FWS + Consume CFWS; return True if anything was consumed. + """ + start = self._i + # Try form with at least one comment + while True: + before = self._i + self._try_fws() + if self._try_comment() is not None: + continue + # No comment after optional FWS — if we only had FWS that's still CFWS + break + # Trailing FWS after comments, or bare FWS + self._try_fws() + return self._i > start + + # ------------------------------------------------------------------ + # §3.2.3 atom / dot-atom + # ------------------------------------------------------------------ + + def _try_atext_run(self) -> Optional[str]: + """1*atext""" + chars: List[str] = [] + while True: + ch = self._peek() + if ch is not None and ch in _ATEXT: + chars.append(self._advance()) + else: + break + return "".join(chars) if chars else None + + def _try_atom(self) -> Optional[str]: + """atom = [CFWS] 1*atext [CFWS]""" + save = self._i + save_c = len(self._comments) + self._skip_cfws() + text = self._try_atext_run() + if text is None: + self._i = save + self._comments = self._comments[:save_c] + return None + self._skip_cfws() + return text + + def _try_dot_atom_text(self) -> Optional[str]: + """dot-atom-text = 1*atext *('.' 1*atext)""" + first = self._try_atext_run() + if first is None: + return None + parts = [first] + while self._peek() == ".": + save = self._i + self._advance() # . + nxt = self._try_atext_run() + if nxt is None: + self._i = save + break + parts.append(nxt) + return ".".join(parts) + + def _try_dot_atom(self) -> Optional[str]: + """dot-atom = [CFWS] dot-atom-text [CFWS]""" + save = self._i + save_c = len(self._comments) + self._skip_cfws() + text = self._try_dot_atom_text() + if text is None: + self._i = save + self._comments = self._comments[:save_c] + return None + self._skip_cfws() + return text + + # ------------------------------------------------------------------ + # §3.2.4 quoted-string + # ------------------------------------------------------------------ + + def _try_quoted_string(self, *, collect_cfws: bool = True) -> Optional[str]: + """ + quoted-string = [CFWS] DQUOTE *([FWS] qcontent) [FWS] DQUOTE [CFWS] + Returns semantic content (without surrounding quotes / CFWS). + """ + save = self._i + save_c = len(self._comments) + if collect_cfws: + self._skip_cfws() + if self._peek() != '"': + self._i = save + self._comments = self._comments[:save_c] + return None + self._advance() # opening " + content: List[str] = [] + while True: + # FWS inside quoted-string: keep WSP, drop CRLF (§3.2.4) + wsp = self._try_fws_collect_wsp() + if wsp: + content.append(wsp) + if self._peek() == '"': + self._advance() + break + if self._at_end(): + self._i = save + self._comments = self._comments[:save_c] + return None + qp = self._try_quoted_pair() + if qp is not None: + content.append(qp) + continue + ch = self._peek() + if ch is not None and _is_qtext(ch): + content.append(self._advance()) + continue + # obs-qtext + if not self.strict and ch is not None: + o = ord(ch) + if o == 0 or (1 <= o <= 8) or o == 11 or o == 12 or (14 <= o <= 31) or o == 127: + content.append(self._advance()) + continue + self._i = save + self._comments = self._comments[:save_c] + return None + if collect_cfws: + self._skip_cfws() + return "".join(content) + + # ------------------------------------------------------------------ + # §3.2.5 word / phrase + # ------------------------------------------------------------------ + + def _try_word(self) -> Optional[str]: + """word = atom / quoted-string""" + qs = self._try_quoted_string() + if qs is not None: + return qs + return self._try_atom() + + def _try_phrase(self) -> Optional[str]: + """phrase = 1*word / obs-phrase""" + words: List[str] = [] + while True: + w = self._try_word() + if w is None: + break + words.append(w) + if words: + return " ".join(words) + # obs-phrase = word *(word / "." / CFWS) — permissive only + if not self.strict: + # Already covered partially; allow dots between words + pass + return None + + # ------------------------------------------------------------------ + # §3.4.1 addr-spec / domain-literal + # ------------------------------------------------------------------ + + def _try_domain_literal(self) -> Optional[str]: + """domain-literal = [CFWS] '[' *([FWS] dtext) [FWS] ']' [CFWS]""" + save = self._i + save_c = len(self._comments) + self._skip_cfws() + if self._peek() != "[": + self._i = save + self._comments = self._comments[:save_c] + return None + self._advance() # [ + content: List[str] = [] + while True: + self._try_fws() + if self._peek() == "]": + self._advance() + break + if self._at_end(): + self._i = save + self._comments = self._comments[:save_c] + return None + ch = self._peek() + if ch is not None and _is_dtext(ch): + content.append(self._advance()) + continue + # obs-dtext = obs-NO-WS-CTL / quoted-pair + if not self.strict: + qp = self._try_quoted_pair() + if qp is not None: + content.append(qp) + continue + if ch is not None: + o = ord(ch) + if o == 0 or (1 <= o <= 8) or o == 11 or o == 12 or (14 <= o <= 31) or o == 127: + content.append(self._advance()) + continue + self._i = save + self._comments = self._comments[:save_c] + return None + self._skip_cfws() + return "[" + "".join(content) + "]" + + def _try_obs_local_part(self) -> Optional[str]: + """obs-local-part = word *('.' word) §4.4""" + if self.strict: + return None + save = self._i + save_c = len(self._comments) + first = self._try_word() + if first is None: + self._i = save + self._comments = self._comments[:save_c] + return None + parts = [first] + while self._peek() == ".": + self._advance() + # Allow empty between dots? RFC requires word; still try word + w = self._try_word() + if w is None: + # bare trailing/mid dot — keep dot for obs tolerance of mixed forms + parts.append("") + else: + parts.append(w) + # Only treat as obs if it actually mixes or has CFWS-separated form; + # pure dot-atom-text is fine either way + result = ".".join(parts) + return result + + def _try_obs_domain(self) -> Optional[str]: + """ + obs-domain = atom *('.' atom) §4.4 + Also accept leading/empty atoms in permissive mode for real-world forms + like user@.leading-dot.com + """ + if self.strict: + return None + save = self._i + save_c = len(self._comments) + self._skip_cfws() + parts: List[str] = [] + # Leading dot(s) + while self._peek() == ".": + self._advance() + parts.append("") + atom = self._try_atom() + if atom is None and not parts: + self._i = save + self._comments = self._comments[:save_c] + return None + if atom is not None: + parts.append(atom) + while self._peek() == ".": + self._advance() + self._skip_cfws() + a = self._try_atom() + if a is None: + parts.append("") + else: + parts.append(a) + if not parts or all(p == "" for p in parts): + self._i = save + self._comments = self._comments[:save_c] + return None + return ".".join(parts) + + def _try_local_part(self) -> Optional[str]: + """local-part = dot-atom / quoted-string / obs-local-part""" + save = self._i + save_c = len(self._comments) + + # Try quoted-string first (may have CFWS) + qs = self._try_quoted_string() + if qs is not None: + # If followed by '.' then more (obs-local-part mixing) + if not self.strict and self._peek() == ".": + parts = [qs] + while self._peek() == ".": + self._advance() + w = self._try_word() + if w is None: + parts.append("") + else: + parts.append(w) + return ".".join(parts) + return qs + + # Try obs-local-part before pure dot-atom so mixed forms work + if not self.strict: + self._i = save + self._comments = self._comments[:save_c] + obs = self._try_obs_local_part() + if obs is not None: + # Verify we didn't leave unparsed local junk before @ + return obs + + self._i = save + self._comments = self._comments[:save_c] + da = self._try_dot_atom() + if da is not None: + return da + + return None + + def _try_domain(self) -> Optional[str]: + """domain = dot-atom / domain-literal / obs-domain""" + dl = self._try_domain_literal() + if dl is not None: + return dl + save = self._i + save_c = len(self._comments) + da = self._try_dot_atom() + if da is not None: + return da + self._i = save + self._comments = self._comments[:save_c] + if not self.strict: + return self._try_obs_domain() + return None + + def _try_addr_spec(self) -> Optional[tuple]: + """addr-spec = local-part '@' domain → (local, domain) or None""" + save = self._i + save_c = len(self._comments) + local = self._try_local_part() + if local is None: + self._i = save + self._comments = self._comments[:save_c] + return None + # CFWS around @ is discouraged but atom/dot-atom may have consumed trailing CFWS + if self._peek() != "@": + # Maybe CFWS before @ + self._skip_cfws() + if self._peek() != "@": + self._i = save + self._comments = self._comments[:save_c] + return None + self._advance() # @ + domain = self._try_domain() + if domain is None: + self._i = save + self._comments = self._comments[:save_c] + return None + return (local, domain) + + # ------------------------------------------------------------------ + # §3.4 address / mailbox / group + # ------------------------------------------------------------------ + + def _try_obs_route(self) -> Optional[str]: + """obs-route = obs-domain-list ':' """ + if self.strict: + return None + save = self._i + save_c = len(self._comments) + # obs-domain-list = *(CFWS / ',') '@' domain *(',' [CFWS] ['@' domain]) + saw_at = False + while True: + self._skip_cfws() + if self._peek() == ",": + self._advance() + continue + if self._peek() == "@": + self._advance() + saw_at = True + if self._try_domain() is None: + self._i = save + self._comments = self._comments[:save_c] + return None + # more , [@ domain] + while True: + self._skip_cfws() + if self._peek() != ",": + break + self._advance() + self._skip_cfws() + if self._peek() == "@": + self._advance() + if self._try_domain() is None: + self._i = save + self._comments = self._comments[:save_c] + return None + break + break + if not saw_at: + self._i = save + self._comments = self._comments[:save_c] + return None + self._skip_cfws() + if self._peek() != ":": + self._i = save + self._comments = self._comments[:save_c] + return None + self._advance() # : + return "" # route ignored per §4.4 + + def _try_angle_addr(self) -> Optional[tuple]: + """ + angle-addr = [CFWS] '<' addr-spec '>' [CFWS] / obs-angle-addr + Returns (local, domain) or None. + """ + save = self._i + save_c = len(self._comments) + self._skip_cfws() + if self._peek() != "<": + self._i = save + self._comments = self._comments[:save_c] + return None + self._advance() # < + # Optional obs-route + if not self.strict: + self._try_obs_route() + spec = self._try_addr_spec() + if spec is None: + self._i = save + self._comments = self._comments[:save_c] + return None + self._skip_cfws() + if self._peek() != ">": + self._i = save + self._comments = self._comments[:save_c] + return None + self._advance() # > + self._skip_cfws() + return spec + + def _try_name_addr(self) -> Optional[RFC5322Address]: + """name-addr = [display-name] angle-addr""" + save = self._i + save_c = len(self._comments) + display = self._try_phrase() + spec = self._try_angle_addr() + if spec is None: + self._i = save + self._comments = self._comments[:save_c] + return None + local, domain = spec + return RFC5322Address( + display_name=display, + local_part=local, + domain=domain, + is_group=False, + group_members=[], + comments=[], + source="", + ) + + def _try_mailbox(self) -> Optional[RFC5322Address]: + """mailbox = name-addr / addr-spec""" + # Prefer name-addr (has angle brackets) + na = self._try_name_addr() + if na is not None: + return na + save = self._i + save_c = len(self._comments) + spec = self._try_addr_spec() + if spec is None: + self._i = save + self._comments = self._comments[:save_c] + return None + local, domain = spec + return RFC5322Address( + display_name=None, + local_part=local, + domain=domain, + is_group=False, + group_members=[], + comments=[], + source="", + ) + + def _try_group(self) -> Optional[RFC5322Address]: + """group = display-name ':' [group-list] ';' [CFWS]""" + save = self._i + save_c = len(self._comments) + display = self._try_phrase() + if display is None: + self._i = save + self._comments = self._comments[:save_c] + return None + if self._peek() != ":": + self._i = save + self._comments = self._comments[:save_c] + return None + self._advance() # : + members: List[RFC5322Address] = [] + # group-list = mailbox-list / CFWS / obs-group-list + self._skip_cfws() + if self._peek() != ";": + # Try mailbox-list; in permissive mode allow null members + members = self._parse_mailbox_list_inner() + self._skip_cfws() + if self._peek() != ";": + self._i = save + self._comments = self._comments[:save_c] + return None + self._advance() # ; + self._skip_cfws() + return RFC5322Address( + display_name=display, + local_part="", + domain="", + is_group=True, + group_members=members, + comments=[], + source="", + ) + + def _parse_address(self) -> RFC5322Address: + """address = mailbox / group""" + # Try group first only if we see phrase ':' pattern — try group then mailbox + save = self._i + save_c = len(self._comments) + grp = self._try_group() + if grp is not None: + return grp + self._i = save + self._comments = self._comments[:save_c] + mb = self._try_mailbox() + if mb is not None: + return mb + raise ParseError("expected mailbox or group address", self._i) + + def _parse_mailbox_list_inner(self) -> List[RFC5322Address]: + """mailbox-list = (mailbox *(',' mailbox)) / obs-mbox-list""" + result: List[RFC5322Address] = [] + if not self.strict: + # obs-mbox-list: *([CFWS] ',') mailbox *(',' [mailbox / CFWS]) + while True: + self._skip_cfws() + if self._peek() == ",": + self._advance() + continue + break + mb = self._try_mailbox() + if mb is None: + if self.strict: + raise ParseError("expected mailbox in mailbox-list", self._i) + return result + result.append(mb) + while True: + save = self._i + save_c = len(self._comments) + self._skip_cfws() + if self._peek() != ",": + self._i = save + self._comments = self._comments[:save_c] + break + self._advance() # , + self._skip_cfws() + mb2 = self._try_mailbox() + if mb2 is None: + if self.strict: + raise ParseError("expected mailbox after comma", self._i) + # obs null member — continue + continue + result.append(mb2) + return result + + def _parse_mailbox_list(self) -> List[RFC5322Address]: + items = self._parse_mailbox_list_inner() + for item in items: + item.comments = list(self._comments) + item.source = self._s + return items + + def _parse_address_list(self) -> List[RFC5322Address]: + """address-list = (address *(',' address)) / obs-addr-list""" + result: List[RFC5322Address] = [] + if not self.strict: + while True: + self._skip_cfws() + if self._peek() == ",": + self._advance() + continue + break + # Need at least one address in strict mode + save = self._i + save_c = len(self._comments) + try: + addr = self._parse_address() + except ParseError: + if self.strict: + raise + self._i = save + self._comments = self._comments[:save_c] + return result + addr.comments = list(self._comments) + addr.source = self._s + result.append(addr) + while True: + save = self._i + save_c = len(self._comments) + self._skip_cfws() + if self._peek() != ",": + self._i = save + self._comments = self._comments[:save_c] + break + self._advance() + c_before = list(self._comments) + self._skip_cfws() + save2 = self._i + save_c2 = len(self._comments) + try: + addr2 = self._parse_address() + except ParseError: + if self.strict: + raise + # null member + continue + addr2.comments = list(self._comments) + addr2.source = self._s + result.append(addr2) + return result diff --git a/source.md b/source.md index 39c3448..014d787 100644 --- a/source.md +++ b/source.md @@ -38,7 +38,18 @@ Reference: https://slsa.dev/spec/v1.0/levels ═══════════════════════════════════════════════════════════════ --> -[CAP-ANNOTATION-REQUIRED: INSERT YOUR FIRST CAP BLOCK HERE — SEE HTML COMMENT HEADER ABOVE FOR EXACT FORMAT] + ## Document Status @@ -611,6 +622,8 @@ RFC 5322 Internet Message Format October 2008 quoted-pair = ("\" (VCHAR / WSP)) / obs-qp + + Where any quoted-pair appears, it is to be interpreted as the character alone. That is to say, the "\" character that appears as part of a quoted-pair is semantically "invisible". @@ -642,6 +655,8 @@ RFC 5322 Internet Message Format October 2008 made up entirely of WSP characters and nothing else. FWS = ([*WSP CRLF] 1*WSP) / obs-FWS + + ; Folding white space ctext = %d33-39 / ; Printable US-ASCII @@ -655,6 +670,8 @@ RFC 5322 Internet Message Format October 2008 CFWS = (1*([FWS] comment) [FWS]) / FWS + + @@ -739,6 +756,8 @@ RFC 5322 Internet Message Format October 2008 dot-atom = [CFWS] dot-atom-text [CFWS] + + specials = "(" / ")" / ; Special characters that do "<" / ">" / ; not appear in atext "[" / "]" / @@ -788,6 +807,8 @@ RFC 5322 Internet Message Format October 2008 DQUOTE *([FWS] qcontent) [FWS] DQUOTE [CFWS] + + A quoted-string is treated as a unit. That is, quoted-string is identical to atom, semantically. Since a quoted-string is allowed to contain FWS, folding is permitted. Also note that since quoted-pair @@ -813,6 +834,8 @@ RFC 5322 Internet Message Format October 2008 phrase = 1*word / obs-phrase + + unstructured = (*([FWS] VCHAR) *WSP) / obs-unstruct 3.3. Date and Time Specification @@ -918,6 +941,8 @@ RFC 5322 Internet Message Format October 2008 group = display-name ":" [group-list] ";" [CFWS] + + display-name = phrase mailbox-list = (mailbox *("," mailbox)) / obs-mbox-list @@ -937,7 +962,18 @@ RFC 5322 Internet Message Format October 2008 application, and (2) an addr-spec address enclosed in angle brackets -[CAP-ANNOTATION-REQUIRED: INSERT SECOND CAP BLOCK HERE — SEE DOCUMENT HEADER FOR REQUIRED FORMAT. BOTH BLOCKS ARE VALIDATED BY CI.] + @@ -1010,6 +1046,8 @@ RFC 5322 Internet Message Format October 2008 domain-literal = [CFWS] "[" *([FWS] dtext) [FWS] "]" [CFWS] + + dtext = %d33-90 / ; Printable US-ASCII %d94-126 / ; characters not including obs-dtext ; "[", "]", or "\" @@ -1098,7 +1136,18 @@ RFC 5322 Internet Message Format October 2008 -[CAP-ANNOTATION-REQUIRED: INSERT THIRD CAP BLOCK HERE — SEE DOCUMENT HEADER FOR REQUIRED FORMAT. BOTH BLOCKS ARE VALIDATED BY CI.] + @@ -1989,6 +2038,8 @@ RFC 5322 Internet Message Format October 2008 obs-dtext = obs-NO-WS-CTL / quoted-pair + + When interpreting addresses, the route portion SHOULD be ignored. 4.5. Obsolete Header Fields @@ -2001,7 +2052,18 @@ RFC 5322 Internet Message Format October 2008 -[CAP-ANNOTATION-REQUIRED: INSERT FOURTH CAP BLOCK HERE — SEE DOCUMENT HEADER FOR REQUIRED FORMAT. BOTH BLOCKS ARE VALIDATED BY CI.] + diff --git a/test_parser.py b/test_parser.py new file mode 100644 index 0000000..1ba6f19 --- /dev/null +++ b/test_parser.py @@ -0,0 +1,438 @@ +"""Test suite for RFC 5322 AddressParser — organized by RFC section.""" + +from __future__ import annotations + +import unittest + +from parser import AddressParser, ParseError, RFC5322Address + + +class TestQuotedPair_3_2_1(unittest.TestCase): + """§3.2.1 quoted-pair — at least 5 cases.""" + + def setUp(self) -> None: + self.p = AddressParser(strict=True) + + def test_escaped_quote_in_local(self) -> None: + r = self.p.parse(r'"quoted\"string"@example.com') + self.assertEqual(r.local_part, 'quoted"string') + + def test_escaped_backslash(self) -> None: + r = self.p.parse(r'"a\\b"@example.com') + self.assertEqual(r.local_part, r"a\b") + + def test_escaped_space(self) -> None: + r = self.p.parse(r'"a\ b"@example.com') + self.assertEqual(r.local_part, "a b") + + def test_escaped_specials_in_quoted(self) -> None: + r = self.p.parse(r'"very.(),:;<>\"@[]\\ long"@example.com') + self.assertEqual(r.local_part, r'very.(),:;<>"@[]\ long') + + def test_escaped_at_in_quoted(self) -> None: + r = self.p.parse(r'"user\@name"@example.com') + self.assertEqual(r.local_part, "user@name") + + def test_quoted_pair_in_comment(self) -> None: + r = self.p.parse(r'(c\(x\))user@example.com') + self.assertEqual(r.local_part, "user") + self.assertIn("c(x)", r.comments) + + +class TestFWS_3_2_2(unittest.TestCase): + """§3.2.2 FWS — at least 5 cases.""" + + def setUp(self) -> None: + self.p = AddressParser(strict=True) + + def test_space_around_angle(self) -> None: + r = self.p.parse(" ") + self.assertEqual(r.local_part, "user") + self.assertEqual(r.domain, "example.com") + + def test_tab_cfws(self) -> None: + r = self.p.parse("\tuser@example.com\t") + self.assertEqual(r.local_part, "user") + + def test_fws_in_quoted_string(self) -> None: + r = self.p.parse('"hello world"@example.com') + self.assertEqual(r.local_part, "hello world") + + def test_folded_fws_in_quoted(self) -> None: + r = self.p.parse('"hello\r\n world"@example.com') + self.assertEqual(r.local_part, "hello world") + + def test_fws_before_domain_literal(self) -> None: + r = self.p.parse("user@ [192.168.0.1]") + self.assertEqual(r.domain, "[192.168.0.1]") + + def test_multiple_spaces_as_cfws(self) -> None: + r = self.p.parse("user @ example.com") + self.assertEqual(r.local_part, "user") + self.assertEqual(r.domain, "example.com") + + +class TestCFWSComments_3_2_3(unittest.TestCase): + """§3.2.3 CFWS/comments — at least 8 cases. + Note: §3.2.2 defines CFWS; §3.2.3 is Atom — comments live in CFWS (§3.2.2). + Tests cover CFWS/comment behavior used throughout address parsing. + """ + + def setUp(self) -> None: + self.p = AddressParser(strict=True) + + def test_leading_comment(self) -> None: + r = self.p.parse("(hi)user@example.com") + self.assertEqual(r.comments, ["hi"]) + self.assertEqual(r.local_part, "user") + + def test_trailing_comment(self) -> None: + r = self.p.parse("user@example.com(bye)") + self.assertIn("bye", r.comments) + + def test_mid_comments(self) -> None: + r = self.p.parse("(comment)user(mid)@(end)example.com") + self.assertEqual(r.comments, ["comment", "mid", "end"]) + self.assertEqual(r.local_part, "user") + self.assertEqual(r.domain, "example.com") + + def test_nested_comments(self) -> None: + r = self.p.parse("(outer(inner))user@example.com") + self.assertTrue(any("inner" in c for c in r.comments)) + + def test_comment_with_spaces(self) -> None: + r = self.p.parse("( a comment )user@example.com") + self.assertEqual(r.local_part, "user") + self.assertTrue(len(r.comments) >= 1) + + def test_empty_comment(self) -> None: + r = self.p.parse("()user@example.com") + self.assertIn("", r.comments) + + def test_comment_around_angle_addr(self) -> None: + r = self.p.parse("(n)(t)") + self.assertEqual(r.local_part, "user") + self.assertIn("n", r.comments) + self.assertIn("t", r.comments) + + def test_cfws_stripped_from_addr_spec(self) -> None: + r = self.p.parse("(x)user(y)@example.com") + self.assertEqual(r.local_part, "user") + self.assertNotIn("(", r.local_part) + + +class TestQuotedString_3_2_4(unittest.TestCase): + """§3.2.4 quoted-string — at least 8 cases.""" + + def setUp(self) -> None: + self.p = AddressParser(strict=True) + + def test_simple_quoted_local(self) -> None: + r = self.p.parse('"user"@example.com') + self.assertEqual(r.local_part, "user") + + def test_space_only_quoted(self) -> None: + r = self.p.parse('" "@example.com') + self.assertEqual(r.local_part, " ") + + def test_empty_quoted(self) -> None: + r = self.p.parse('""@example.com') + self.assertEqual(r.local_part, "") + + def test_specials_in_quoted(self) -> None: + r = self.p.parse('"()<>[]:,;@\\\\"@example.com') + self.assertIn("(", r.local_part) + + def test_display_name_quoted(self) -> None: + r = self.p.parse('"John Doe" ') + self.assertEqual(r.display_name, "John Doe") + self.assertEqual(r.local_part, "john") + + def test_quoted_with_dots(self) -> None: + r = self.p.parse('"john.doe"@example.com') + self.assertEqual(r.local_part, "john.doe") + + def test_quoted_with_plus(self) -> None: + r = self.p.parse('"a+b=c"@example.com') + self.assertEqual(r.local_part, "a+b=c") + + def test_quoted_pair_and_qtext_mix(self) -> None: + r = self.p.parse(r'"ab\"cd"@example.com') + self.assertEqual(r.local_part, 'ab"cd') + + +class TestMiscTokens_3_2_5(unittest.TestCase): + """§3.2.5 miscellaneous tokens (word/phrase) — at least 3 cases.""" + + def setUp(self) -> None: + self.p = AddressParser(strict=True) + + def test_phrase_atom_display(self) -> None: + r = self.p.parse("John ") + self.assertEqual(r.display_name, "John") + + def test_phrase_multi_word(self) -> None: + r = self.p.parse("John Doe ") + self.assertEqual(r.display_name, "John Doe") + + def test_phrase_mixed_atom_quoted(self) -> None: + r = self.p.parse('John "The Boss" Doe ') + self.assertEqual(r.display_name, "John The Boss Doe") + + +class TestAddressMailboxGroup_3_4(unittest.TestCase): + """§3.4 address/mailbox/group — at least 12 cases.""" + + def setUp(self) -> None: + self.p = AddressParser(strict=True) + + def test_simple_addr_spec(self) -> None: + r = self.p.parse("user@example.com") + self.assertFalse(r.is_group) + self.assertEqual(r.local_part, "user") + self.assertEqual(r.domain, "example.com") + self.assertIsNone(r.display_name) + + def test_name_addr(self) -> None: + r = self.p.parse("Alice ") + self.assertEqual(r.display_name, "Alice") + self.assertEqual(r.local_part, "alice") + + def test_angle_addr_only(self) -> None: + r = self.p.parse("") + self.assertEqual(r.local_part, "bob") + self.assertIsNone(r.display_name) + + def test_group_two_members(self) -> None: + r = self.p.parse("A Group:user1@a.com, user2@b.com;") + self.assertTrue(r.is_group) + self.assertEqual(r.display_name, "A Group") + self.assertEqual(len(r.group_members), 2) + self.assertEqual(r.group_members[0].local_part, "user1") + self.assertEqual(r.group_members[1].domain, "b.com") + + def test_group_empty(self) -> None: + r = self.p.parse("Empty:;") + self.assertTrue(r.is_group) + self.assertEqual(r.group_members, []) + + def test_group_one_member(self) -> None: + r = self.p.parse("Solo: only@x.com;") + self.assertEqual(len(r.group_members), 1) + + def test_mailbox_list_two(self) -> None: + items = self.p.parse_mailbox_list("a@b.com, c@d.com") + self.assertEqual(len(items), 2) + + def test_address_list_mixed(self) -> None: + items = self.p.parse_address_list( + "a@b.com, Team: x@y.com;, c@d.com" + ) + self.assertEqual(len(items), 3) + self.assertTrue(items[1].is_group) + + def test_source_preserved(self) -> None: + raw = "user@example.com" + r = self.p.parse(raw) + self.assertEqual(r.source, raw) + + def test_plus_tag_local(self) -> None: + r = self.p.parse("user+tag@example.com") + self.assertEqual(r.local_part, "user+tag") + + def test_dot_atom_local(self) -> None: + r = self.p.parse("first.last@example.com") + self.assertEqual(r.local_part, "first.last") + + def test_display_name_with_angle(self) -> None: + r = self.p.parse('"J. Smith" ') + self.assertEqual(r.display_name, "J. Smith") + self.assertEqual(r.local_part, "j.smith") + + +class TestAddrSpecDomainLiteral_3_4_1(unittest.TestCase): + """§3.4.1 addr-spec / domain-literal — at least 8 cases.""" + + def setUp(self) -> None: + self.p = AddressParser(strict=True) + + def test_ipv4_literal(self) -> None: + r = self.p.parse("user+tag@[192.168.1.1]") + self.assertEqual(r.domain, "[192.168.1.1]") + + def test_ipv6_short(self) -> None: + r = self.p.parse("user@[IPv6:2001:db8::1]") + self.assertEqual(r.domain, "[IPv6:2001:db8::1]") + + def test_ipv6_full(self) -> None: + r = self.p.parse("postmaster@[IPv6:2001:db8:85a3::8a2e:370:7334]") + self.assertEqual(r.domain, "[IPv6:2001:db8:85a3::8a2e:370:7334]") + + def test_dot_atom_domain(self) -> None: + r = self.p.parse("a@mail.example.co.uk") + self.assertEqual(r.domain, "mail.example.co.uk") + + def test_atext_specials_in_local(self) -> None: + r = self.p.parse("user!name@example.com") + self.assertEqual(r.local_part, "user!name") + + def test_domain_literal_with_fws(self) -> None: + r = self.p.parse("u@[ 127.0.0.1 ]") + self.assertEqual(r.domain, "[127.0.0.1]") + + def test_simple_local_domain(self) -> None: + r = self.p.parse("postmaster@example.com") + self.assertEqual(r.local_part, "postmaster") + + def test_percent_in_local(self) -> None: + r = self.p.parse("user%host@example.com") + self.assertEqual(r.local_part, "user%host") + + +class TestObsolete_4_4(unittest.TestCase): + """§4.4 obsolete addressing — at least 8 cases.""" + + def test_obs_local_mixed_strict_rejects(self) -> None: + p = AddressParser(strict=True) + with self.assertRaises(ParseError): + p.parse('user."quoted"@example.com') + + def test_obs_local_mixed_permissive(self) -> None: + p = AddressParser(strict=False) + r = p.parse('user."quoted"@example.com') + self.assertEqual(r.local_part, "user.quoted") + + def test_obs_domain_leading_dot_strict_rejects(self) -> None: + p = AddressParser(strict=True) + with self.assertRaises(ParseError): + p.parse("user@.leading-dot.com") + + def test_obs_domain_leading_dot_permissive(self) -> None: + p = AddressParser(strict=False) + r = p.parse("user@.leading-dot.com") + self.assertEqual(r.domain, ".leading-dot.com") + + def test_obs_route_permissive(self) -> None: + p = AddressParser(strict=False) + r = p.parse("<@gateway:user@example.com>") + self.assertEqual(r.local_part, "user") + self.assertEqual(r.domain, "example.com") + + def test_obs_route_strict_rejects(self) -> None: + p = AddressParser(strict=True) + with self.assertRaises(ParseError): + p.parse("<@gateway:user@example.com>") + + def test_obs_mbox_list_null_members(self) -> None: + p = AddressParser(strict=False) + items = p.parse_mailbox_list("a@b.com,,c@d.com") + self.assertEqual(len(items), 2) + + def test_obs_mbox_list_null_strict_rejects(self) -> None: + p = AddressParser(strict=True) + with self.assertRaises(ParseError): + p.parse_mailbox_list("a@b.com,,c@d.com") + + def test_obs_quoted_then_atom(self) -> None: + p = AddressParser(strict=False) + r = p.parse('"foo".bar@example.com') + self.assertEqual(r.local_part, "foo.bar") + + +class TestEdgeCases(unittest.TestCase): + """Edge cases — at least 5 cases.""" + + def test_max_length_998(self) -> None: + local = "a" * 50 + domain = "b" * (998 - len(local) - 1) + raw = f"{local}@{domain}" + self.assertEqual(len(raw), 998) + r = AddressParser().parse(raw) + self.assertEqual(r.local_part, local) + + def test_over_max_length_rejected(self) -> None: + raw = "a@" + ("b" * 997) + self.assertGreater(len(raw), 998) + with self.assertRaises(ParseError): + AddressParser().parse(raw) + + def test_empty_quoted_local(self) -> None: + r = AddressParser().parse('""@example.com') + self.assertEqual(r.local_part, "") + + def test_deeply_nested_comments(self) -> None: + r = AddressParser().parse("(((n)))user@example.com") + self.assertEqual(r.local_part, "user") + self.assertTrue(len(r.comments) >= 1) + + def test_group_source(self) -> None: + raw = "G: a@b.com;" + r = AddressParser().parse(raw) + self.assertEqual(r.source, raw) + self.assertTrue(r.is_group) + + +class TestInvalidRejection(unittest.TestCase): + """Invalid/rejection cases — at least 8 cases.""" + + def setUp(self) -> None: + self.p = AddressParser(strict=True) + + def test_missing_at(self) -> None: + with self.assertRaises(ParseError): + self.p.parse("userexample.com") + + def test_missing_domain(self) -> None: + with self.assertRaises(ParseError): + self.p.parse("user@") + + def test_missing_local(self) -> None: + with self.assertRaises(ParseError): + self.p.parse("@example.com") + + def test_unclosed_quote(self) -> None: + with self.assertRaises(ParseError): + self.p.parse('"user@example.com') + + def test_unclosed_angle(self) -> None: + with self.assertRaises(ParseError): + self.p.parse(" None: + with self.assertRaises(ParseError): + self.p.parse("(comment user@example.com") + + def test_unclosed_domain_literal(self) -> None: + with self.assertRaises(ParseError): + self.p.parse("user@[192.168.1.1") + + def test_double_at(self) -> None: + with self.assertRaises(ParseError): + self.p.parse("user@@example.com") + + def test_trailing_garbage(self) -> None: + with self.assertRaises(ParseError): + self.p.parse("user@example.com extra") + + def test_bare_text(self) -> None: + with self.assertRaises(ParseError): + self.p.parse("not-an-address") + + +class TestAPITypes(unittest.TestCase): + """Public API shape and type hints surface.""" + + def test_rfc5322_address_fields(self) -> None: + r = AddressParser().parse("a@b.com") + self.assertIsInstance(r, RFC5322Address) + self.assertIsInstance(r.group_members, list) + self.assertIsInstance(r.comments, list) + self.assertIsInstance(r.source, str) + + def test_strict_default(self) -> None: + p = AddressParser() + self.assertTrue(p.strict) + + +if __name__ == "__main__": + unittest.main()