From 42f6bce9a41820dec45bb461a23a6966d61f8f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Thu, 28 May 2026 16:59:30 +0900 Subject: [PATCH] feat(parser): implement fully ABNF-compliant email address parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full ABNF grammar from §3.2-§3.4 with optional obsolete syntax support from §4.4. Includes 70 unit tests and a compliance matrix. Closes #1 --- compliance.md | 41 ++++ parser.py | 575 +++++++++++++++++++++++++++++++++++++++++++++++++ source.md | 52 ++++- test_parser.py | 442 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1106 insertions(+), 4 deletions(-) create mode 100644 compliance.md create mode 100644 parser.py create mode 100644 test_parser.py diff --git a/compliance.md b/compliance.md new file mode 100644 index 0000000..bafc5df --- /dev/null +++ b/compliance.md @@ -0,0 +1,41 @@ +# RFC 5322 ABNF Grammar Compliance Matrix + +This document maps all the standard and obsolete ABNF productions defined in RFC 5322 (and utilized for address parsing) to their implementation status and the corresponding test cases in `test_parser.py`. + +| ABNF Production | RFC Section | Test Case(s) in `test_parser.py` | Status | +|-----------------|-------------|----------------------------------|--------| +| `address` | §3.4 | `test_mailbox_simple`, `test_group_empty`, `test_address_list_mixed` | Complete | +| `mailbox` | §3.4 | `test_mailbox_simple`, `test_name_addr_simple`, `test_name_addr_no_display` | Complete | +| `name-addr` | §3.4 | `test_name_addr_simple`, `test_name_addr_quoted`, `test_name_addr_no_display` | Complete | +| `angle-addr` | §3.4 | `test_name_addr_simple`, `test_name_addr_no_display`, `test_obs_angle_addr_route` | Complete | +| `addr-spec` | §3.4.1 | `test_mailbox_simple`, `test_dot_atom_local_part`, `test_domain_literal_ipv4` | Complete | +| `local-part` | §3.4.1 | `test_quoted_string_simple`, `test_dot_atom_local_part`, `test_obs_local_part_mixed` | Complete | +| `domain` | §3.4.1 | `test_dot_atom_domain`, `test_domain_literal_ipv4`, `test_obs_domain_cfws_around_dots` | Complete | +| `display-name` | §3.4 | `test_misc_display_name_mixed`, `test_misc_display_name_multiple_atoms`, `test_name_addr_quoted` | Complete | +| `phrase` | §3.2.5 | `test_misc_display_name_multiple_atoms`, `test_misc_display_name_mixed` | Complete | +| `word` | §3.2.5 | `test_misc_display_name_mixed`, `test_misc_display_name_multiple_atoms` | Complete | +| `atom` | §3.2.3 | `test_misc_atom_all_atext`, `test_misc_display_name_multiple_atoms` | Complete | +| `atext` | §3.2.3 | `test_misc_atom_all_atext` | Complete | +| `quoted-string` | §3.2.4 | `test_quoted_string_simple`, `test_quoted_string_with_space`, `test_quoted_string_escaped_quote` | Complete | +| `qcontent` | §3.2.4 | `test_quoted_string_escaped_quote`, `test_quoted_string_with_space` | Complete | +| `qtext` | §3.2.4 | `test_quoted_string_simple`, `test_quoted_string_with_space` | Complete | +| `quoted-pair` | §3.2.1 | `test_quoted_pair_escaped_char`, `test_quoted_pair_escaped_quote`, `test_quoted_pair_escaped_backslash` | Complete | +| `dot-atom` | §3.2.3 | `test_dot_atom_domain`, `test_dot_atom_local_part` | Complete | +| `dot-atom-text` | §3.2.3 | `test_dot_atom_domain`, `test_dot_atom_local_part` | Complete | +| `domain-literal` | §3.4.1 | `test_domain_literal_ipv4`, `test_domain_literal_ipv6`, `test_domain_literal_spaces` | Complete | +| `dtext` | §3.4.1 | `test_domain_literal_ipv4`, `test_domain_literal_ipv6` | Complete | +| `group` | §3.4 | `test_group_empty`, `test_group_one_member`, `test_group_multiple_members` | Complete | +| `group-list` | §3.4 | `test_group_one_member`, `test_group_multiple_members`, `test_obs_group_list_null_elements` | Complete | +| `mailbox-list` | §3.4 | `test_mailbox_list`, `test_group_multiple_members` | Complete | +| `address-list` | §3.4 | `test_address_list_mixed`, `test_obs_address_list_null_elements` | Complete | +| `CFWS` | §3.2.2 | `test_cfws_prepended`, `test_cfws_middle`, `test_cfws_domain_start`, `test_cfws_appended`, `test_cfws_multiple_comments` | Complete | +| `comment` | §3.2.2 | `test_cfws_nested_comments`, `test_cfws_escaped_parentheses`, `test_cfws_fws_inside`, `test_edge_deeply_nested_comments` | Complete | +| `ccontent` | §3.2.2 | `test_cfws_nested_comments`, `test_cfws_escaped_parentheses` | Complete | +| `ctext` | §3.2.2 | `test_cfws_prepended`, `test_cfws_nested_comments` | Complete | +| `FWS` | §3.2.2 | `test_fws_in_local_part`, `test_fws_in_quoted_string`, `test_fws_multiple_in_quoted`, `test_cfws_fws_inside` | Complete | +| `obs-local-part` | §4.4 | `test_obs_local_part_mixed`, `test_obs_local_part_multiple_quotes` | Complete | +| `obs-domain` | §4.4 | `test_obs_domain_cfws_around_dots` | Complete | +| `obs-route` | §4.4 | `test_obs_angle_addr_route`, `test_obs_angle_addr_multi_route` | Complete | +| `obs-group-list` | §4.4 | `test_obs_group_list_null_elements` | Complete | +| `obs-addr-list` | §4.4 | `test_obs_address_list_null_elements` | Complete | +| `obs-qtext` | §4.4 | `test_obs_qtext_ctrl_char` | Complete | diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..ceabc96 --- /dev/null +++ b/parser.py @@ -0,0 +1,575 @@ +from typing import List, Optional, Tuple + +class RFC5322Address: + """Parsed RFC 5322 email address.""" + def __init__(self) -> None: + self.display_name: Optional[str] = None + self.local_part: str = "" + self.domain: str = "" + self.is_group: bool = False + self.group_members: List['RFC5322Address'] = [] + self.comments: List[str] = [] + self.source: str = "" + + def __repr__(self) -> str: + if self.is_group: + members_str = ", ".join(repr(m) for m in self.group_members) + return f"Group(name={repr(self.display_name)}, members=[{members_str}], comments={self.comments})" + else: + return f"Mailbox(name={repr(self.display_name)}, local_part={repr(self.local_part)}, domain={repr(self.domain)}, comments={self.comments})" + +def is_atext(c: str) -> bool: + if not c: + return False + if c.isalnum(): + return True + return c in "!#$%&'*+-/=?^_`{|}~" + +def is_qtext(c: str, strict: bool) -> bool: + if not c: + return False + o = ord(c) + if o == 33 or (35 <= o <= 91) or (93 <= o <= 126): + return True + if not strict: + if (1 <= o <= 8) or o == 11 or o == 12 or (14 <= o <= 31) or o == 127: + return True + return False + +def is_ctext(c: str, strict: bool) -> bool: + if not c: + return False + o = ord(c) + if (33 <= o <= 39) or (42 <= o <= 91) or (93 <= o <= 126): + return True + if not strict: + if (1 <= o <= 8) or o == 11 or o == 12 or (14 <= o <= 31) or o == 127: + return True + return False + +def is_dtext(c: str, strict: bool) -> bool: + if not c: + return False + o = ord(c) + if (33 <= o <= 90) or (94 <= o <= 126): + return True + if not strict: + if (1 <= o <= 8) or o == 11 or o == 12 or (14 <= o <= 31) or o == 127: + return True + return False + +def unquote_string(s: str) -> str: + if s.startswith('"') and s.endswith('"') and len(s) >= 2: + content = s[1:-1] + res = [] + i = 0 + while i < len(content): + if content[i] == '\\' and i + 1 < len(content): + res.append(content[i+1]) + i += 2 + else: + res.append(content[i]) + i += 1 + return "".join(res) + return s + +class ParserState: + def __init__(self, s: str, strict: bool) -> None: + self.s = s + self.strict = strict + self.pos = 0 + self.comments: List[str] = [] + + def parse_fws(self) -> str: + start = self.pos + wsp1 = 0 + while self.pos < len(self.s) and self.s[self.pos] in " \t": + self.pos += 1 + wsp1 += 1 + + has_crlf = False + if self.pos + 1 < len(self.s) and self.s[self.pos:self.pos+2] == "\r\n": + self.pos += 2 + has_crlf = True + elif not self.strict and self.pos < len(self.s) and self.s[self.pos] in "\r\n": + self.pos += 1 + has_crlf = True + + if has_crlf: + wsp2 = 0 + while self.pos < len(self.s) and self.s[self.pos] in " \t": + self.pos += 1 + wsp2 += 1 + if wsp2 == 0: + self.pos = start + wsp1 + return self.s[start:start+wsp1] + return self.s[start:self.pos] + return self.s[start:self.pos] + + def parse_comment(self) -> str: + if self.pos >= len(self.s) or self.s[self.pos] != '(': + raise ValueError("Expected '(' at start of comment") + self.pos += 1 + + comment_content = [] + + while self.pos < len(self.s): + fws_str = self.parse_fws() + if fws_str: + comment_content.append(fws_str) + + if self.pos >= len(self.s): + raise ValueError("Unclosed comment") + + c = self.s[self.pos] + if c == ')': + self.pos += 1 + return "".join(comment_content) + elif c == '(': + nested = self.parse_comment() + comment_content.append("(" + nested + ")") + elif c == '\\': + qp = self.parse_quoted_pair() + comment_content.append(qp) + elif is_ctext(c, self.strict): + comment_content.append(c) + self.pos += 1 + elif not self.strict and ord(c) in [10, 13]: + comment_content.append(c) + self.pos += 1 + else: + raise ValueError(f"Invalid character in comment: {repr(c)}") + + raise ValueError("Unclosed comment") + + def parse_cfws(self) -> None: + while True: + self.parse_fws() + if self.pos < len(self.s) and self.s[self.pos] == '(': + comment_text = self.parse_comment() + self.comments.append(comment_text) + else: + break + self.parse_fws() + + def parse_quoted_pair(self) -> str: + if self.pos >= len(self.s) or self.s[self.pos] != '\\': + raise ValueError("Expected '\\' at start of quoted-pair") + if self.pos + 1 >= len(self.s): + raise ValueError("Trailing '\\' at end of input") + val = self.s[self.pos + 1] + o = ord(val) + if self.strict: + if not (33 <= o <= 126 or o == 32 or o == 9): + raise ValueError(f"Invalid character in quoted-pair in strict mode: {repr(val)}") + self.pos += 2 + return val + + def parse_quoted_string(self) -> str: + if self.pos >= len(self.s) or self.s[self.pos] != '"': + raise ValueError("Expected '\"' at start of quoted-string") + + start_pos = self.pos + self.pos += 1 + + while self.pos < len(self.s): + self.parse_fws() + if self.pos >= len(self.s): + raise ValueError("Unclosed quoted-string") + + c = self.s[self.pos] + if c == '"': + self.pos += 1 + return self.s[start_pos:self.pos] + elif c == '\\': + self.parse_quoted_pair() + elif is_qtext(c, self.strict): + self.pos += 1 + elif not self.strict and ord(c) in [10, 13]: + self.pos += 1 + else: + raise ValueError(f"Invalid character in quoted-string: {repr(c)}") + + raise ValueError("Unclosed quoted-string") + + def parse_domain_literal(self) -> str: + if self.pos >= len(self.s) or self.s[self.pos] != '[': + raise ValueError("Expected '[' at start of domain-literal") + + start_pos = self.pos + self.pos += 1 + + while self.pos < len(self.s): + self.parse_fws() + if self.pos >= len(self.s): + raise ValueError("Unclosed domain-literal") + + c = self.s[self.pos] + if c == ']': + self.pos += 1 + return self.s[start_pos:self.pos] + elif c == '\\': + if self.strict: + raise ValueError("Quoted-pair not allowed in domain-literal in strict mode") + else: + self.parse_quoted_pair() + elif is_dtext(c, self.strict): + self.pos += 1 + else: + raise ValueError(f"Invalid character in domain-literal: {repr(c)}") + + raise ValueError("Unclosed domain-literal") + + def parse_dot_atom_text(self) -> str: + start_pos = self.pos + if self.pos >= len(self.s) or not is_atext(self.s[self.pos]): + raise ValueError("Expected atext at start of dot-atom-text") + self.pos += 1 + + while self.pos < len(self.s) and is_atext(self.s[self.pos]): + self.pos += 1 + + while self.pos < len(self.s) and self.s[self.pos] == '.': + if self.pos + 1 >= len(self.s) or not is_atext(self.s[self.pos + 1]): + break + self.pos += 2 + while self.pos < len(self.s) and is_atext(self.s[self.pos]): + self.pos += 1 + + return self.s[start_pos:self.pos] + + def parse_word(self) -> str: + self.parse_cfws() + if self.pos >= len(self.s): + raise ValueError("Expected word") + + if self.s[self.pos] == '"': + val = self.parse_quoted_string() + self.parse_cfws() + return val + else: + start_pos = self.pos + if not is_atext(self.s[self.pos]): + raise ValueError("Expected atext inside atom") + while self.pos < len(self.s) and is_atext(self.s[self.pos]): + self.pos += 1 + val = self.s[start_pos:self.pos] + self.parse_cfws() + return val + + def parse_local_part(self) -> str: + self.parse_cfws() + + if self.pos >= len(self.s): + raise ValueError("Empty local-part") + + if self.strict: + if self.s[self.pos] == '"': + lp_val = self.parse_quoted_string() + self.parse_cfws() + return lp_val + else: + lp_val = self.parse_dot_atom_text() + self.parse_cfws() + return lp_val + else: + words = [] + words.append(self.parse_word()) + + while self.pos < len(self.s) and self.s[self.pos] == '.': + self.pos += 1 + words.append(self.parse_word()) + + return ".".join(words) + + def parse_atom_value(self) -> str: + self.parse_cfws() + if self.pos >= len(self.s) or not is_atext(self.s[self.pos]): + raise ValueError("Expected atext inside atom") + start = self.pos + while self.pos < len(self.s) and is_atext(self.s[self.pos]): + self.pos += 1 + val = self.s[start:self.pos] + self.parse_cfws() + return val + + def parse_domain(self) -> str: + self.parse_cfws() + if self.pos >= len(self.s): + raise ValueError("Empty domain") + + if self.s[self.pos] == '[': + val = self.parse_domain_literal() + self.parse_cfws() + return val + + if self.strict: + val = self.parse_dot_atom_text() + self.parse_cfws() + return val + else: + atoms = [] + atoms.append(self.parse_atom_value()) + while self.pos < len(self.s) and self.s[self.pos] == '.': + self.pos += 1 + atoms.append(self.parse_atom_value()) + return ".".join(atoms) + + def parse_addr_spec(self) -> Tuple[str, str]: + lp = self.parse_local_part() + + if self.pos >= len(self.s) or self.s[self.pos] != '@': + raise ValueError("Expected '@' after local-part") + self.pos += 1 + + dom = self.parse_domain() + return lp, dom + + def parse_angle_addr(self) -> Tuple[str, str]: + self.parse_cfws() + if self.pos >= len(self.s) or self.s[self.pos] != '<': + raise ValueError("Expected '<' at start of angle-addr") + self.pos += 1 + + self.parse_cfws() + + if not self.strict: + colon_pos = -1 + depth = 0 + for i in range(self.pos, len(self.s)): + if self.s[i] == '(': + depth += 1 + elif self.s[i] == ')': + depth -= 1 + elif self.s[i] == '>' and depth == 0: + break + elif self.s[i] == ':' and depth == 0: + colon_pos = i + break + if colon_pos != -1: + self.pos = colon_pos + 1 + self.parse_cfws() + + lp, dom = self.parse_addr_spec() + + self.parse_cfws() + if self.pos >= len(self.s) or self.s[self.pos] != '>': + raise ValueError("Expected '>' at end of angle-addr") + self.pos += 1 + + self.parse_cfws() + return lp, dom + + def parse_phrase(self) -> str: + words = [] + self.parse_cfws() + + while self.pos < len(self.s): + temp_pos = self.pos + self.parse_cfws() + if self.pos >= len(self.s) or self.s[self.pos] in "<:": + self.pos = temp_pos + break + + if not self.strict and self.s[self.pos] == '.': + words.append(".") + self.pos += 1 + self.parse_cfws() + continue + + if self.s[self.pos] == '"': + qs = self.parse_quoted_string() + words.append(unquote_string(qs)) + else: + start_atom = self.pos + if not is_atext(self.s[self.pos]): + self.pos = temp_pos + break + while self.pos < len(self.s) and is_atext(self.s[self.pos]): + self.pos += 1 + words.append(self.s[start_atom:self.pos]) + + self.parse_cfws() + + if not words: + raise ValueError("Expected phrase") + + return " ".join(words) + + def parse_mailbox(self) -> RFC5322Address: + start_pos = self.pos + comments_before = len(self.comments) + + try: + disp_name = None + temp_pos = self.pos + self.parse_cfws() + has_angle = (self.pos < len(self.s) and self.s[self.pos] == '<') + self.pos = temp_pos + + if not has_angle: + disp_name = self.parse_phrase() + + lp, dom = self.parse_angle_addr() + + addr = RFC5322Address() + addr.display_name = disp_name + addr.local_part = lp + addr.domain = dom + addr.is_group = False + addr.group_members = [] + addr.comments = self.comments[comments_before:] + addr.source = self.s[start_pos:self.pos] + return addr + + except ValueError: + self.pos = start_pos + self.comments = self.comments[:comments_before] + + lp, dom = self.parse_addr_spec() + + addr = RFC5322Address() + addr.display_name = None + addr.local_part = lp + addr.domain = dom + addr.is_group = False + addr.group_members = [] + addr.comments = self.comments[comments_before:] + addr.source = self.s[start_pos:self.pos] + return addr + + def parse_group(self) -> RFC5322Address: + start_pos = self.pos + comments_before = len(self.comments) + + disp_name = self.parse_phrase() + + if self.pos >= len(self.s) or self.s[self.pos] != ':': + raise ValueError("Expected ':' after display-name in group") + self.pos += 1 + + self.parse_cfws() + + members = [] + if self.pos < len(self.s) and self.s[self.pos] == ';': + pass + else: + while self.pos < len(self.s): + self.parse_cfws() + if self.pos < len(self.s) and self.s[self.pos] == ';': + break + + if not self.strict and self.s[self.pos] == ',': + self.pos += 1 + continue + + members.append(self.parse_mailbox()) + + self.parse_cfws() + if self.pos < len(self.s) and self.s[self.pos] == ',': + self.pos += 1 + elif self.pos < len(self.s) and self.s[self.pos] == ';': + break + else: + raise ValueError("Expected ',' or ';' in group-list") + + if self.pos >= len(self.s) or self.s[self.pos] != ';': + raise ValueError("Expected ';' at end of group") + self.pos += 1 + + self.parse_cfws() + + addr = RFC5322Address() + addr.display_name = disp_name + addr.local_part = "" + addr.domain = "" + addr.is_group = True + addr.group_members = members + addr.comments = self.comments[comments_before:] + addr.source = self.s[start_pos:self.pos] + return addr + + def parse_address(self) -> RFC5322Address: + start_pos = self.pos + comments_before = len(self.comments) + try: + return self.parse_group() + except ValueError: + self.pos = start_pos + self.comments = self.comments[:comments_before] + return self.parse_mailbox() + +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. + """ + def __init__(self, strict: bool = True) -> None: + self.strict = strict + + def parse(self, raw: str) -> RFC5322Address: + """Parse a single mailbox or group address.""" + if len(raw) > 998: + raise ValueError("Input exceeds maximum line length limit of 998 characters") + state = ParserState(raw, self.strict) + state.parse_cfws() + addr = state.parse_address() + state.parse_cfws() + if state.pos < len(state.s): + raise ValueError(f"Trailing garbage after address: {repr(state.s[state.pos:])}") + addr.comments = state.comments + return addr + + def parse_address_list(self, raw: str) -> List[RFC5322Address]: + """Parse a comma-separated address-list per §3.4.""" + if len(raw) > 998: + raise ValueError("Input exceeds maximum line length limit of 998 characters") + state = ParserState(raw, self.strict) + addresses = [] + + while state.pos < len(state.s): + if not self.strict and state.s[state.pos] == ',': + state.pos += 1 + continue + + state.comments = [] + state.parse_cfws() + addr = state.parse_address() + state.parse_cfws() + addr.comments = state.comments + addresses.append(addr) + + if state.pos < len(state.s) and state.s[state.pos] == ',': + state.pos += 1 + elif state.pos < len(state.s): + raise ValueError("Expected ',' between addresses in list") + + return addresses + + def parse_mailbox_list(self, raw: str) -> List[RFC5322Address]: + """Parse a comma-separated mailbox-list per §3.4.""" + if len(raw) > 998: + raise ValueError("Input exceeds maximum line length limit of 998 characters") + state = ParserState(raw, self.strict) + mailboxes = [] + + while state.pos < len(state.s): + if not self.strict and state.s[state.pos] == ',': + state.pos += 1 + continue + + state.comments = [] + state.parse_cfws() + addr = state.parse_mailbox() + state.parse_cfws() + addr.comments = state.comments + mailboxes.append(addr) + + if state.pos < len(state.s) and state.s[state.pos] == ',': + state.pos += 1 + elif state.pos < len(state.s): + raise ValueError("Expected ',' between mailboxes in list") + + return mailboxes diff --git a/source.md b/source.md index 39c3448..fa39a70 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 @@ -937,7 +948,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.] + @@ -1098,7 +1120,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.] + @@ -2001,7 +2034,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..25b75bf --- /dev/null +++ b/test_parser.py @@ -0,0 +1,442 @@ +import pytest +from parser import AddressParser, RFC5322Address + +# ========================================== +# §3.2.1 Quoted-Pair Tests (at least 5 cases) +# ========================================== + +def test_quoted_pair_escaped_char(): + # Strict mode allows escaped VCHAR or WSP + parser = AddressParser(strict=True) + addr = parser.parse('"john\\\\doe"@example.com') + assert addr.local_part == '"john\\\\doe"' + + addr = parser.parse('"john\\ doe"@example.com') + assert addr.local_part == '"john\\ doe"' + +def test_quoted_pair_escaped_quote(): + parser = AddressParser(strict=True) + addr = parser.parse('"john\\"doe"@example.com') + assert addr.local_part == '"john\\"doe"' + +def test_quoted_pair_escaped_backslash(): + parser = AddressParser(strict=True) + addr = parser.parse('"john\\\\\\\\doe"@example.com') + assert addr.local_part == '"john\\\\\\\\doe"' + +def test_quoted_pair_escaped_tab(): + parser = AddressParser(strict=True) + addr = parser.parse('"john\\\tdoe"@example.com') + assert addr.local_part == '"john\\\tdoe"' + +def test_quoted_pair_invalid_strict(): + parser = AddressParser(strict=True) + # Strict mode rejects NUL character escaped + with pytest.raises(ValueError): + parser.parse('"john\\\x00doe"@example.com') + +# ========================================== +# §3.2.2 FWS Tests (at least 5 cases) +# ========================================== + +def test_fws_in_local_part(): + parser = AddressParser(strict=True) + # FWS is standard WSP CRLF WSP + addr = parser.parse('john \r\n @example.com') + assert addr.local_part == 'john' + assert addr.domain == 'example.com' + +def test_fws_in_quoted_string(): + parser = AddressParser(strict=True) + addr = parser.parse('"john \r\n doe"@example.com') + assert addr.local_part == '"john \r\n doe"' + +def test_fws_multiple_in_quoted(): + parser = AddressParser(strict=True) + addr = parser.parse('"a \r\n b \r\n c"@example.com') + assert addr.local_part == '"a \r\n b \r\n c"' + +def test_fws_invalid_crlf(): + parser = AddressParser(strict=True) + # CRLF must be followed by at least one WSP + with pytest.raises(ValueError): + parser.parse('john\r\n@example.com') + +def test_fws_permissive_lf_only(): + parser = AddressParser(strict=False) + # Permissive mode allows just \n or \r + addr = parser.parse('john\n @example.com') + assert addr.local_part == 'john' + +# ========================================== +# §3.2.3 CFWS/Comments Tests (at least 8 cases) +# ========================================== + +def test_cfws_prepended(): + parser = AddressParser(strict=True) + addr = parser.parse('(comment)user@example.com') + assert addr.local_part == 'user' + assert addr.comments == ['comment'] + +def test_cfws_middle(): + parser = AddressParser(strict=True) + addr = parser.parse('user(middle)@example.com') + assert addr.local_part == 'user' + assert addr.comments == ['middle'] + +def test_cfws_domain_start(): + parser = AddressParser(strict=True) + addr = parser.parse('user@(end)example.com') + assert addr.domain == 'example.com' + assert addr.comments == ['end'] + +def test_cfws_appended(): + parser = AddressParser(strict=True) + addr = parser.parse('user@example.com(tail)') + assert addr.domain == 'example.com' + assert addr.comments == ['tail'] + +def test_cfws_multiple_comments(): + parser = AddressParser(strict=True) + addr = parser.parse('(c1)user(c2)@(c3)example.com(c4)') + assert addr.comments == ['c1', 'c2', 'c3', 'c4'] + +def test_cfws_nested_comments(): + parser = AddressParser(strict=True) + addr = parser.parse('(outer (inner) comment)user@example.com') + assert addr.comments == ['outer (inner) comment'] + +def test_cfws_escaped_parentheses(): + parser = AddressParser(strict=True) + addr = parser.parse('(escaped \\( paren)user@example.com') + assert addr.comments == ['escaped ( paren'] + + addr = parser.parse('(escaped \\) paren)user@example.com') + assert addr.comments == ['escaped ) paren'] + +def test_cfws_fws_inside(): + parser = AddressParser(strict=True) + addr = parser.parse('(comment \r\n with fws)user@example.com') + assert addr.comments == ['comment \r\n with fws'] + +# ========================================== +# §3.2.4 Quoted-String Tests (at least 8 cases) +# ========================================== + +def test_quoted_string_simple(): + parser = AddressParser(strict=True) + addr = parser.parse('"john"@example.com') + assert addr.local_part == '"john"' + +def test_quoted_string_with_space(): + parser = AddressParser(strict=True) + addr = parser.parse('"john doe"@example.com') + assert addr.local_part == '"john doe"' + +def test_quoted_string_escaped_quote(): + parser = AddressParser(strict=True) + addr = parser.parse('"quoted\\"string"@example.com') + assert addr.local_part == '"quoted\\"string"' + +def test_quoted_string_escaped_backslash(): + parser = AddressParser(strict=True) + addr = parser.parse('"quoted\\\\string"@example.com') + assert addr.local_part == '"quoted\\\\string"' + +def test_quoted_string_all_special_chars(): + parser = AddressParser(strict=True) + addr = parser.parse('"very.(),:;<>\\"@[]\\\\ long"@example.com') + assert addr.local_part == '"very.(),:;<>\\"@[]\\\\ long"' + +def test_quoted_string_space_only(): + parser = AddressParser(strict=True) + addr = parser.parse('" "@example.com') + assert addr.local_part == '" "' + +def test_quoted_string_empty(): + parser = AddressParser(strict=True) + addr = parser.parse('""@example.com') + assert addr.local_part == '""' + +def test_quoted_string_nested_cfws(): + parser = AddressParser(strict=True) + addr = parser.parse('(outer)"quoted"(inner)@example.com') + assert addr.local_part == '"quoted"' + assert addr.comments == ['outer', 'inner'] + +# ========================================== +# §3.2.5 Miscellaneous Tokens Tests (at least 3 cases) +# ========================================== + +def test_misc_display_name_mixed(): + parser = AddressParser(strict=True) + addr = parser.parse('John "Doe" ') + assert addr.display_name == 'John Doe' + assert addr.local_part == 'john' + assert addr.domain == 'example.com' + +def test_misc_display_name_multiple_atoms(): + parser = AddressParser(strict=True) + addr = parser.parse('John Middle Doe ') + assert addr.display_name == 'John Middle Doe' + +def test_misc_atom_all_atext(): + parser = AddressParser(strict=True) + addr = parser.parse("!#$%&'*+-/=?^_`{|}~@example.com") + assert addr.local_part == "!#$%&'*+-/=?^_`{|}~" + +# ========================================== +# §3.4 Address/Mailbox/Group Tests (at least 12 cases) +# ========================================== + +def test_mailbox_simple(): + parser = AddressParser(strict=True) + addr = parser.parse('user@example.com') + assert not addr.is_group + assert addr.local_part == 'user' + +def test_name_addr_simple(): + parser = AddressParser(strict=True) + addr = parser.parse('John Doe ') + assert addr.display_name == 'John Doe' + assert addr.local_part == 'john' + assert addr.domain == 'example.com' + +def test_name_addr_quoted(): + parser = AddressParser(strict=True) + addr = parser.parse('"John Doe" ') + assert addr.display_name == 'John Doe' + +def test_name_addr_no_display(): + parser = AddressParser(strict=True) + addr = parser.parse('') + assert addr.display_name is None + assert addr.local_part == 'john' + +def test_group_empty(): + parser = AddressParser(strict=True) + addr = parser.parse('A Group:;') + assert addr.is_group + assert addr.display_name == 'A Group' + assert len(addr.group_members) == 0 + +def test_group_one_member(): + parser = AddressParser(strict=True) + addr = parser.parse('A Group:user1@example.com;') + assert addr.is_group + assert len(addr.group_members) == 1 + assert addr.group_members[0].local_part == 'user1' + +def test_group_multiple_members(): + parser = AddressParser(strict=True) + addr = parser.parse('A Group:user1@example.com, user2@example.com;') + assert addr.is_group + assert len(addr.group_members) == 2 + assert addr.group_members[0].local_part == 'user1' + assert addr.group_members[1].local_part == 'user2' + +def test_group_quoted_display_name(): + parser = AddressParser(strict=True) + addr = parser.parse('"Special Group":user1@example.com;') + assert addr.is_group + assert addr.display_name == 'Special Group' + +def test_address_list_mixed(): + parser = AddressParser(strict=True) + addr_list = parser.parse_address_list('user1@a.com, A Group:user2@b.com, user3@c.com;, user4@d.com') + assert len(addr_list) == 3 + assert addr_list[0].local_part == 'user1' + assert addr_list[1].is_group + assert len(addr_list[1].group_members) == 2 + assert addr_list[2].local_part == 'user4' + +def test_mailbox_list(): + parser = AddressParser(strict=True) + mb_list = parser.parse_mailbox_list('user1@a.com, user2@b.com, "User 3" ') + assert len(mb_list) == 3 + assert mb_list[2].display_name == 'User 3' + +def test_group_with_comments(): + parser = AddressParser(strict=True) + addr = parser.parse('Group (desc): user1@example.com (c1);') + assert addr.is_group + # Group comments should include description and member's comments + assert addr.comments == ['desc', 'c1'] + assert addr.group_members[0].comments == ['c1'] + +def test_mailbox_list_rejects_group(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse_mailbox_list('user1@a.com, Group:;') + +# ========================================== +# §3.4.1 Addr-Spec/Domain-Literal (at least 8 cases) +# ========================================== + +def test_domain_literal_ipv4(): + parser = AddressParser(strict=True) + addr = parser.parse('user+tag@[192.168.1.1]') + assert addr.domain == '[192.168.1.1]' + +def test_domain_literal_ipv6(): + parser = AddressParser(strict=True) + addr = parser.parse('user@[IPv6:2001:db8::1]') + assert addr.domain == '[IPv6:2001:db8::1]' + +def test_domain_literal_spaces(): + parser = AddressParser(strict=True) + addr = parser.parse('user@[ 192.168.1.1 ]') + assert addr.domain == '[ 192.168.1.1 ]' + +def test_domain_literal_fws(): + parser = AddressParser(strict=True) + addr = parser.parse('user@[\r\n 192.168.1.1]') + assert addr.domain == '[\r\n 192.168.1.1]' + +def test_domain_literal_full_ipv6(): + parser = AddressParser(strict=True) + addr = parser.parse('postmaster@[IPv6:2001:db8:85a3::8a2e:370:7334]') + assert addr.domain == '[IPv6:2001:db8:85a3::8a2e:370:7334]' + +def test_dot_atom_domain(): + parser = AddressParser(strict=True) + addr = parser.parse('user@sub.domain.example.com') + assert addr.domain == 'sub.domain.example.com' + +def test_dot_atom_local_part(): + parser = AddressParser(strict=True) + addr = parser.parse('first.last@example.com') + assert addr.local_part == 'first.last' + +def test_domain_literal_strict_rejects_quoted_pair(): + parser = AddressParser(strict=True) + # Strict mode rejects backslash in domain literal + with pytest.raises(ValueError): + parser.parse('user@[192.168.\\1.1]') + +# ========================================== +# §4.4 Obsolete Addressing (at least 8 cases) +# ========================================== + +def test_obs_local_part_mixed(): + parser = AddressParser(strict=False) + addr = parser.parse('user."quoted"@example.com') + assert addr.local_part == 'user."quoted"' + +def test_obs_domain_cfws_around_dots(): + parser = AddressParser(strict=False) + addr = parser.parse('user@example (desc) . (desc) com') + assert addr.domain == 'example.com' + assert addr.comments == ['desc', 'desc'] + +def test_obs_local_part_multiple_quotes(): + parser = AddressParser(strict=False) + addr = parser.parse('"first"."second"@example.com') + assert addr.local_part == '"first"."second"' + +def test_obs_angle_addr_route(): + parser = AddressParser(strict=False) + addr = parser.parse('<@route.com:user@example.com>') + assert addr.local_part == 'user' + assert addr.domain == 'example.com' + +def test_obs_angle_addr_multi_route(): + parser = AddressParser(strict=False) + addr = parser.parse('<@a.com,@b.com:user@example.com>') + assert addr.local_part == 'user' + assert addr.domain == 'example.com' + +def test_obs_address_list_null_elements(): + parser = AddressParser(strict=False) + addr_list = parser.parse_address_list(', user1@example.com,, user2@example.com,') + assert len(addr_list) == 2 + assert addr_list[0].local_part == 'user1' + assert addr_list[1].local_part == 'user2' + +def test_obs_group_list_null_elements(): + parser = AddressParser(strict=False) + addr = parser.parse('Group:user1@example.com,,user2@example.com;') + assert addr.is_group + assert len(addr.group_members) == 2 + +def test_obs_qtext_ctrl_char(): + parser = AddressParser(strict=False) + # Permissive allows ASCII 1 control character inside quoted-string + addr = parser.parse('"ctrl\x01char"@example.com') + assert addr.local_part == '"ctrl\x01char"' + +# ========================================== +# Edge Cases (at least 5 cases) +# ========================================== + +def test_edge_max_length_limit(): + parser = AddressParser(strict=True) + # 998 characters is fine + long_local = 'a' * 980 + addr = parser.parse(f'{long_local}@example.com') + assert len(addr.local_part) == 980 + +def test_edge_exceed_length_limit(): + parser = AddressParser(strict=True) + long_local = 'a' * 990 + with pytest.raises(ValueError): + parser.parse(f'{long_local}@example.com') # exceeds 998 chars total + +def test_edge_deeply_nested_comments(): + parser = AddressParser(strict=True) + addr = parser.parse('(c1 (c2 (c3 (c4 (c5)))))user@example.com') + assert addr.comments == ['c1 (c2 (c3 (c4 (c5))))'] + +def test_edge_empty_input(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('') + +def test_edge_only_comments(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('(only comments)') + +# ========================================== +# Invalid / Rejection Cases (at least 8 cases) +# ========================================== + +def test_invalid_missing_at(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('userexample.com') + +def test_invalid_trailing_dot(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('user.@example.com') + +def test_invalid_leading_dot(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('.user@example.com') + +def test_invalid_unclosed_comment(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('(comment user@example.com') + +def test_invalid_unclosed_quotes(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('"user@example.com') + +def test_invalid_unclosed_domain_literal(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('user@[192.168.1.1') + +def test_invalid_consecutive_dots(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('user..name@example.com') + +def test_invalid_special_chars_outside_quotes(): + parser = AddressParser(strict=True) + with pytest.raises(ValueError): + parser.parse('user[]@example.com')