diff --git a/compliance.md b/compliance.md new file mode 100644 index 0000000..ce72076 --- /dev/null +++ b/compliance.md @@ -0,0 +1,42 @@ +# RFC 5322 Compliance Map + +This document maps each ABNF production exercised by the parser to the parser method(s) and explicit test methods in `test_parser.py`. + +| ABNF production | RFC section | Parser method(s) | Test cases exercising it | Status | +| --- | --- | --- | --- | --- | +| `address` | RFC 5322 3.4 | `AddressParser.parse()` / `_parse_any_address()` | `Test34AddressMailboxGroup.test_addr_spec_only_mailbox`
`Test34AddressMailboxGroup.test_name_addr_with_display_name`
`Test34AddressMailboxGroup.test_group_with_two_members`
`Test44Obsolete.test_obs_angle_addr_with_route` | Covered | +| `mailbox` | RFC 5322 3.4 | `AddressParser.parse()` / `_parse_mailbox()` | `Test34AddressMailboxGroup.test_addr_spec_only_mailbox`
`Test34AddressMailboxGroup.test_name_addr_with_display_name`
`Test34AddressMailboxGroup.test_angle_addr_without_display_name`
`Test34AddressMailboxGroup.test_mailbox_list_two_items`
`Test44Obsolete.test_obs_mailbox_list_with_leading_commas`
`Test44Obsolete.test_obs_mailbox_list_with_trailing_commas` | Covered | +| `name-addr` | RFC 5322 3.4 | `_parse_name_addr()` | `Test34AddressMailboxGroup.test_name_addr_with_display_name`
`Test34AddressMailboxGroup.test_angle_addr_without_display_name`
`Test34AddressMailboxGroup.test_mailbox_with_leading_cfws`
`Test34AddressMailboxGroup.test_mailbox_with_trailing_cfws`
`Test44Obsolete.test_obs_angle_addr_with_route`
`Test44Obsolete.test_obs_angle_addr_without_display_name` | Covered | +| `angle-addr` | RFC 5322 3.4 | `_parse_name_addr()` | `Test34AddressMailboxGroup.test_angle_addr_without_display_name`
`Test44Obsolete.test_obs_angle_addr_with_route`
`Test44Obsolete.test_obs_angle_addr_without_display_name` | Covered | +| `addr-spec` | RFC 5322 3.4.1 | `_parse_addr_spec()` / `_parse_addr_spec_body()` | `Test34AddressMailboxGroup.test_addr_spec_only_mailbox`
`Test341AddrSpecDomainLiteral.test_simple_dot_atom_addr_spec`
`Test341AddrSpecDomainLiteral.test_quoted_local_part_addr_spec`
`Test341AddrSpecDomainLiteral.test_ipv4_domain_literal`
`Test341AddrSpecDomainLiteral.test_ipv6_domain_literal`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_plain_text` | Covered | +| `local-part` | RFC 5322 3.4.1 | `_parse_local_part()` | `Test341AddrSpecDomainLiteral.test_simple_dot_atom_addr_spec`
`Test341AddrSpecDomainLiteral.test_quoted_local_part_addr_spec`
`Test324QuotedString.test_quoted_string_empty_local_part`
`Test324QuotedString.test_quoted_string_with_spaces`
`Test44Obsolete.test_obs_local_part_with_quoted_word`
`Test44Obsolete.test_obs_local_part_with_cfws` | Covered | +| `domain` | RFC 5322 3.4.1 | `_parse_domain()` | `Test341AddrSpecDomainLiteral.test_simple_dot_atom_addr_spec`
`Test341AddrSpecDomainLiteral.test_ipv4_domain_literal`
`Test341AddrSpecDomainLiteral.test_ipv6_domain_literal`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_plain_text`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_cfws`
`Test44Obsolete.test_obs_domain_with_cfws` | Covered | +| `dot-atom` | RFC 5322 3.2.4, used in RFC 5322 3.4.1 | `_parse_dot_atom()` | `Test325MiscTokens.test_atom_local_part_with_plus`
`Test325MiscTokens.test_dot_atom_domain_with_multiple_labels`
`Test341AddrSpecDomainLiteral.test_simple_dot_atom_addr_spec`
`Test34AddressMailboxGroup.test_addr_spec_only_mailbox`
`Test44Obsolete.test_obs_local_part_with_cfws`
`Test44Obsolete.test_obs_domain_with_cfws` | Covered | +| `quoted-string` | RFC 5322 3.2.5 | `_parse_quoted_string()` | `Test321QuotedPair.test_quoted_pair_in_quoted_string_escaped_double_quote`
`Test321QuotedPair.test_quoted_pair_in_quoted_string_escaped_backslash`
`Test324QuotedString.test_quoted_string_empty_local_part`
`Test324QuotedString.test_quoted_string_with_spaces`
`Test324QuotedString.test_quoted_string_with_escaped_quote`
`Test324QuotedString.test_quoted_string_with_escaped_backslash`
`Test324QuotedString.test_quoted_string_with_folded_whitespace`
`Test324QuotedString.test_quoted_string_with_escaped_comma`
`Test324QuotedString.test_quoted_string_as_display_name`
`Test324QuotedString.test_quoted_string_with_punctuation_chars`
`Test44Obsolete.test_obs_local_part_with_quoted_word` | Covered | +| `CFWS` | RFC 5322 3.2.2-3.2.3 | `_skip_cfws()` / `_parse_comment()` / `_consume_fws()` | `Test322FWS.test_fws_single_spaces_in_display_name`
`Test322FWS.test_fws_tab_in_display_name`
`Test322FWS.test_fws_crlf_in_display_name`
`Test322FWS.test_fws_in_quoted_string_local_part`
`Test322FWS.test_fws_in_address_list`
`Test323CFWSComments.test_leading_and_trailing_comments_around_addr_spec`
`Test323CFWSComments.test_comments_between_display_name_words`
`Test323CFWSComments.test_nested_comment_text_collected`
`Test323CFWSComments.test_comments_before_angle_addr`
`Test323CFWSComments.test_comments_around_at_sign`
`Test323CFWSComments.test_comments_on_domain_side`
`Test323CFWSComments.test_comments_in_group_name`
`Test323CFWSComments.test_comments_in_address_list_items`
`Test34AddressMailboxGroup.test_mailbox_with_leading_cfws`
`Test34AddressMailboxGroup.test_mailbox_with_trailing_cfws`
`Test34AddressMailboxGroup.test_address_list_with_comment_wrapped_items`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_cfws`
`Test44Obsolete.test_obs_group_list_with_comments` | Covered | +| `FWS` | RFC 5322 3.2.2 | `_starts_fws()` / `_consume_fws()` | `Test322FWS.test_fws_single_spaces_in_display_name`
`Test322FWS.test_fws_tab_in_display_name`
`Test322FWS.test_fws_crlf_in_display_name`
`Test322FWS.test_fws_in_quoted_string_local_part`
`Test322FWS.test_fws_in_address_list` | Covered | +| `comment` | RFC 5322 3.2.3 | `_parse_comment()` | `Test321QuotedPair.test_quoted_pair_in_comment`
`Test322FWS.test_fws_in_address_list`
`Test323CFWSComments.test_leading_and_trailing_comments_around_addr_spec`
`Test323CFWSComments.test_comments_between_display_name_words`
`Test323CFWSComments.test_nested_comment_text_collected`
`Test323CFWSComments.test_comments_before_angle_addr`
`Test323CFWSComments.test_comments_around_at_sign`
`Test323CFWSComments.test_comments_on_domain_side`
`Test323CFWSComments.test_comments_in_group_name`
`Test323CFWSComments.test_comments_in_address_list_items`
`Test44Obsolete.test_obs_group_list_with_comments` | Covered | +| `quoted-pair` | RFC 5322 3.2.1 | `_parse_quoted_pair()` | `Test321QuotedPair.test_quoted_pair_in_quoted_string_escaped_double_quote`
`Test321QuotedPair.test_quoted_pair_in_quoted_string_escaped_backslash`
`Test321QuotedPair.test_quoted_pair_in_quoted_string_escaped_at_sign`
`Test321QuotedPair.test_quoted_pair_in_quoted_string_escaped_space`
`Test321QuotedPair.test_quoted_pair_in_comment`
`Test324QuotedString.test_quoted_string_with_escaped_quote`
`Test324QuotedString.test_quoted_string_with_escaped_backslash`
`Test324QuotedString.test_quoted_string_with_escaped_comma` | Covered | +| `dtext` | RFC 5322 3.4.1 | `_parse_domain_literal()` / `_is_dtext()` | `Test341AddrSpecDomainLiteral.test_ipv4_domain_literal`
`Test341AddrSpecDomainLiteral.test_ipv6_domain_literal`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_plain_text`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_dtext_punctuation`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_cfws` | Covered | +| `domain-literal` | RFC 5322 3.4.1 | `_parse_domain_literal()` | `Test341AddrSpecDomainLiteral.test_ipv4_domain_literal`
`Test341AddrSpecDomainLiteral.test_ipv6_domain_literal`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_plain_text`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_dtext_punctuation`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_cfws`
`TestEdgeCases.test_empty_domain_literal` | Covered | +| `atom` | RFC 5322 3.2.4 | `_parse_atom_text()` / `_parse_atom_text_with_cfws()` | `Test325MiscTokens.test_atom_local_part_with_plus`
`Test325MiscTokens.test_dot_atom_domain_with_multiple_labels`
`Test325MiscTokens.test_phrase_with_atoms_and_quoted_word`
`Test341AddrSpecDomainLiteral.test_domain_literal_with_plain_text`
`Test323CFWSComments.test_comments_between_display_name_words` | Covered | +| `word` | RFC 5322 3.2.4-3.2.5 | `_parse_word_in_obs()` / `_parse_quoted_string()` / `_parse_atom_text_with_cfws()` | `Test324QuotedString.test_quoted_string_as_display_name`
`Test325MiscTokens.test_phrase_with_atoms_and_quoted_word`
`Test44Obsolete.test_obs_local_part_with_quoted_word` | Covered | +| `phrase` | RFC 5322 3.2.4 | `_parse_phrase()` | `Test323CFWSComments.test_comments_between_display_name_words`
`Test323CFWSComments.test_comments_in_group_name`
`Test324QuotedString.test_quoted_string_as_display_name`
`Test325MiscTokens.test_phrase_with_atoms_and_quoted_word`
`Test34AddressMailboxGroup.test_name_addr_with_display_name`
`Test34AddressMailboxGroup.test_address_list_two_name_addrs` | Covered | +| `address-list` | RFC 5322 3.4 | `AddressParser.parse_address_list()` / `_parse_address_list(kind="address")` | `Test322FWS.test_fws_in_address_list`
`Test323CFWSComments.test_comments_in_address_list_items`
`Test34AddressMailboxGroup.test_address_list_two_name_addrs`
`Test34AddressMailboxGroup.test_group_in_address_list`
`Test34AddressMailboxGroup.test_address_list_with_comment_wrapped_items`
`Test44Obsolete.test_obs_address_list_with_route_and_mailbox`
`Test44Obsolete.test_obs_group_list_with_comments`
`Test44Obsolete.test_obs_mailbox_list_with_leading_commas` | Covered | +| `mailbox-list` | RFC 5322 3.4 | `AddressParser.parse_mailbox_list()` / `_parse_address_list(kind="mailbox")` | `Test34AddressMailboxGroup.test_mailbox_list_two_items`
`Test341AddrSpecDomainLiteral.test_mailbox_list_with_domain_literals`
`Test44Obsolete.test_obs_mailbox_list_with_leading_commas`
`Test44Obsolete.test_obs_mailbox_list_with_trailing_commas` | Covered | +| `group` | RFC 5322 3.4 | `_parse_group()` | `Test34AddressMailboxGroup.test_group_with_two_members`
`Test34AddressMailboxGroup.test_empty_group`
`Test34AddressMailboxGroup.test_group_with_comment_only_members`
`Test34AddressMailboxGroup.test_group_in_address_list`
`Test44Obsolete.test_obs_group_list_only_commas`
`Test44Obsolete.test_obs_group_list_with_comments` | Covered | +| `group-list` | RFC 5322 3.4 | `_parse_group_list()` | `Test34AddressMailboxGroup.test_group_with_two_members`
`Test34AddressMailboxGroup.test_group_in_address_list`
`Test44Obsolete.test_obs_group_list_only_commas`
`Test44Obsolete.test_obs_group_list_with_comments` | Covered | +| `obs-local-part` | RFC 5322 4.4 | `_parse_obs_local_part()` | `Test44Obsolete.test_obs_local_part_with_quoted_word`
`Test44Obsolete.test_obs_local_part_with_cfws`
`TestInvalidRejection.test_reject_obsolete_local_part_in_strict_mode` | Covered | +| `obs-domain` | RFC 5322 4.4 | `_parse_obs_domain()` | `Test44Obsolete.test_obs_domain_with_cfws`
`TestInvalidRejection.test_reject_obsolete_domain_in_strict_mode` | Covered | +| `obs-angle-addr` | RFC 5322 4.4 | `_parse_name_addr()` / `_parse_obs_route()` | `Test44Obsolete.test_obs_angle_addr_with_route`
`Test44Obsolete.test_obs_angle_addr_without_display_name`
`TestInvalidRejection.test_reject_obsolete_route_in_strict_mode` | Covered | +| `obs-route` | RFC 5322 4.4 | `_parse_obs_route()` | `Test44Obsolete.test_obs_angle_addr_with_route`
`Test44Obsolete.test_obs_angle_addr_without_display_name`
`TestInvalidRejection.test_reject_obsolete_route_in_strict_mode` | Covered | +| `obs-group-list` | RFC 5322 4.4 | `_parse_group_list()` in permissive mode | `Test44Obsolete.test_obs_group_list_only_commas`
`Test44Obsolete.test_obs_group_list_with_comments`
`TestInvalidRejection.test_reject_group_missing_semicolon` | Covered | +| `obs-mbox-list` | RFC 5322 4.4 | `_parse_address_list(kind="mailbox")` in permissive mode | `Test44Obsolete.test_obs_mailbox_list_with_leading_commas`
`Test44Obsolete.test_obs_mailbox_list_with_trailing_commas` | Covered | +| `obs-addr-list` | RFC 5322 4.4 | `_parse_address_list(kind="address")` in permissive mode | `Test44Obsolete.test_obs_address_list_with_route_and_mailbox`
`Test44Obsolete.test_obs_group_list_with_comments` | Covered | + +## Notes + +- `AddressParser(strict=True)` rejects obsolete productions and malformed list members. +- `AddressParser(strict=False)` accepts the RFC 5322 section 4.4 obsolete mailbox, route, and list forms. +- `RFC5322Address.comments` captures collected CFWS/comment text for the cases above. +- `RFC5322Address.source` preserves the original parsed slice for top-level address parses. diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..496d42e --- /dev/null +++ b/parser.py @@ -0,0 +1,638 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import ipaddress +from typing import Iterable + + +@dataclass(slots=True) +class RFC5322Address: + display_name: str | None + local_part: str | None + domain: str | None + is_group: bool + group_members: list[RFC5322Address] = field(default_factory=list) + comments: list[str] = field(default_factory=list) + source: str = "" + + +class AddressParser: + def __init__(self, strict: bool = True) -> None: + self.strict = strict + self._raw = "" + self._len = 0 + self._i = 0 + + def parse(self, raw: str) -> RFC5322Address: + self._start(raw) + value = self._parse_any_address() + self._skip_cfws(value.comments) + self._expect_eof() + value.source = raw + return value + + def parse_address_list(self, raw: str) -> list[RFC5322Address]: + self._start(raw) + items = self._parse_address_list(kind="address") + self._skip_cfws(None) + self._expect_eof() + return items + + def parse_mailbox_list(self, raw: str) -> list[RFC5322Address]: + self._start(raw) + items = self._parse_address_list(kind="mailbox") + self._skip_cfws(None) + self._expect_eof() + return items + + def _start(self, raw: str) -> None: + if not isinstance(raw, str): + raise TypeError("raw must be a string") + self._raw = raw + self._len = len(raw) + self._i = 0 + + def _parse_address_list(self, kind: str) -> list[RFC5322Address]: + items: list[RFC5322Address] = [] + saw_separator = False + pending_comments: list[str] = [] + while self._i < self._len: + self._skip_cfws(pending_comments) + if self._peek() == ",": + saw_separator = True + if self.strict: + raise ValueError("empty list member is not allowed in strict mode") + self._i += 1 + continue + start = self._i + if kind == "mailbox": + item = self._parse_mailbox() + else: + item = self._parse_any_address() + if pending_comments: + item.comments = pending_comments + item.comments + pending_comments = [] + item.source = self._raw[start:self._i] + items.append(item) + if self._i < self._len and self._peek() == ",": + saw_separator = True + self._i += 1 + continue + break + + if not items and not saw_separator: + # An entirely empty or CFWS-only input is treated as an empty list + # only in permissive mode. + if self.strict: + raise ValueError("expected at least one list member") + return items + + def _parse_any_address(self) -> RFC5322Address: + pos = self._classify_structure() + if pos == "group": + return self._parse_group() + if pos == "name-addr": + return self._parse_name_addr() + if pos == "addr-spec": + return self._parse_addr_spec() + raise ValueError("expected address or group") + + def _parse_mailbox(self) -> RFC5322Address: + pos = self._classify_structure() + if pos == "group": + raise ValueError("group is not allowed in mailbox list") + if pos == "name-addr": + return self._parse_name_addr() + if pos == "addr-spec": + return self._parse_addr_spec() + raise ValueError("expected mailbox") + + def _parse_group(self) -> RFC5322Address: + comments: list[str] = [] + start = self._i + display_name = self._parse_phrase(stop_chars={":"}, comments=comments) + self._skip_cfws(comments) + self._expect(":") + self._i += 1 + self._skip_cfws(comments) + members: list[RFC5322Address] = [] + if self._i < self._len and self._peek() != ";": + members = self._parse_group_list() + self._skip_cfws(comments) + self._expect(";") + self._i += 1 + comments_after = comments + self._skip_cfws(comments_after) + return RFC5322Address( + display_name=display_name, + local_part=None, + domain=None, + is_group=True, + group_members=members, + comments=comments_after, + source=self._raw[start:self._i], + ) + + def _parse_group_list(self) -> list[RFC5322Address]: + items: list[RFC5322Address] = [] + pending_comments: list[str] = [] + while self._i < self._len and self._peek() != ";": + self._skip_cfws(pending_comments) + if self._i < self._len and self._peek() == ";": + break + if self._peek() == ",": + if self.strict: + raise ValueError("empty group-list member is not allowed in strict mode") + self._i += 1 + continue + start = self._i + item = self._parse_mailbox() + if pending_comments: + item.comments = pending_comments + item.comments + pending_comments = [] + item.source = self._raw[start:self._i] + items.append(item) + if self._i < self._len and self._peek() == ",": + self._i += 1 + continue + break + return items + + def _parse_name_addr(self) -> RFC5322Address: + comments: list[str] = [] + start = self._i + display_name: str | None = None + self._skip_cfws(comments) + if self._peek() != "<": + display_name = self._parse_phrase(stop_chars={"<"}, comments=comments) + self._skip_cfws(comments) + self._expect("<") + self._i += 1 + self._skip_cfws(comments) + if not self.strict and self._peek() == "@": + self._parse_obs_route(comments) + local_part, domain = self._parse_addr_spec_body(comments) + self._skip_cfws(comments) + self._expect(">") + self._i += 1 + self._skip_cfws(comments) + return RFC5322Address( + display_name=display_name, + local_part=local_part, + domain=domain, + is_group=False, + group_members=[], + comments=comments, + source=self._raw[start:self._i], + ) + + def _parse_addr_spec(self) -> RFC5322Address: + comments: list[str] = [] + start = self._i + local_part, domain = self._parse_addr_spec_body(comments) + self._skip_cfws(comments) + return RFC5322Address( + display_name=None, + local_part=local_part, + domain=domain, + is_group=False, + group_members=[], + comments=comments, + source=self._raw[start:self._i], + ) + + def _parse_addr_spec_body(self, comments: list[str]) -> tuple[str, str]: + local_part = self._parse_local_part(comments) + self._skip_cfws(comments) + self._expect("@") + self._i += 1 + self._skip_cfws(comments) + domain = self._parse_domain(comments) + return local_part, domain + + def _parse_local_part(self, comments: list[str]) -> str: + if self._peek() == '"': + return self._parse_quoted_string(comments) + save = self._i + try: + return self._parse_dot_atom(comments) + except ValueError: + self._i = save + if self.strict: + raise + return self._parse_obs_local_part(comments) + + def _parse_domain(self, comments: list[str]) -> str: + if self._peek() == "[": + return self._parse_domain_literal(comments) + save = self._i + try: + return self._parse_dot_atom(comments) + except ValueError: + self._i = save + if self.strict: + raise + return self._parse_obs_domain(comments) + + def _parse_dot_atom(self, comments: list[str]) -> str: + self._skip_cfws(comments) + parts = [self._parse_atom_text_with_cfws(comments)] + while self._i < self._len and self._peek() == ".": + self._i += 1 + if self._i >= self._len or not self._is_atext_start(self._peek()): + raise ValueError("expected atext after dot") + parts.append(self._parse_atom_text_with_cfws(comments)) + self._skip_cfws(comments) + return ".".join(parts) + + def _parse_obs_local_part(self, comments: list[str]) -> str: + parts = [self._parse_word_in_obs(comments)] + while True: + checkpoint = self._i + self._skip_cfws(None) + if self._i >= self._len or self._peek() != ".": + self._i = checkpoint + break + self._i += 1 + self._skip_cfws(None) + parts.append(self._parse_word_in_obs(comments)) + return ".".join(parts) + + def _parse_obs_domain(self, comments: list[str]) -> str: + parts = [self._parse_atom_text_with_cfws(comments)] + while True: + checkpoint = self._i + self._skip_cfws(None) + if self._i >= self._len or self._peek() != ".": + self._i = checkpoint + break + self._i += 1 + self._skip_cfws(None) + parts.append(self._parse_atom_text_with_cfws(comments)) + return ".".join(parts) + + def _parse_word_in_obs(self, comments: list[str]) -> str: + if self._peek() == '"': + return self._parse_quoted_string(comments) + return self._parse_atom_text_with_cfws(comments) + + def _parse_atom_text(self) -> str: + self._skip_cfws(None) + start = self._i + while self._i < self._len and self._is_atext(self._peek()): + self._i += 1 + if self._i == start: + raise ValueError("expected atom") + value = self._raw[start:self._i] + self._skip_cfws(None) + return value + + def _parse_atom_text_with_cfws(self, comments: list[str]) -> str: + self._skip_cfws(comments) + start = self._i + while self._i < self._len and self._is_atext(self._peek()): + self._i += 1 + if self._i == start: + raise ValueError("expected atom") + value = self._raw[start:self._i] + self._skip_cfws(comments) + return value + + def _parse_phrase(self, stop_chars: set[str], comments: list[str]) -> str: + parts: list[str] = [] + while True: + self._skip_cfws(comments) + ch = self._peek() + if ch in stop_chars: + break + if ch == '"': + parts.append(self._parse_quoted_string(comments)) + continue + if self._is_atext_start(ch): + parts.append(self._parse_atom_text_with_cfws(comments)) + continue + if not self.strict and ch == "." and parts: + self._i += 1 + parts[-1] += "." + continue + if parts: + break + raise ValueError("expected phrase") + if not parts: + raise ValueError("expected phrase") + return " ".join(parts).strip() + + def _parse_quoted_string(self, comments: list[str]) -> str: + self._skip_cfws(comments) + self._expect('"') + self._i += 1 + parts: list[str] = [] + while self._i < self._len: + if self._peek() == '"': + self._i += 1 + self._skip_cfws(comments) + return "".join(parts) + if self._starts_fws(): + self._consume_fws() + parts.append(" ") + continue + ch = self._peek() + if ch == "\\": + parts.append(self._parse_quoted_pair()) + continue + if self._is_qtext(ch): + parts.append(ch) + self._i += 1 + continue + raise ValueError("invalid quoted-string content") + raise ValueError("unterminated quoted-string") + + def _parse_domain_literal(self, comments: list[str]) -> str: + self._skip_cfws(comments) + self._expect("[") + self._i += 1 + parts: list[str] = [] + while self._i < self._len: + if self._peek() == "]": + self._i += 1 + self._skip_cfws(comments) + value = "".join(parts) + self._validate_domain_literal(value) + return value + if self._starts_fws(): + self._consume_fws() + parts.append(" ") + continue + if self._peek() == "\\": + if self.strict: + raise ValueError("quoted-pair is not permitted in domain-literal in strict mode") + parts.append(self._parse_quoted_pair()) + continue + ch = self._peek() + if self._is_dtext(ch): + parts.append(ch) + self._i += 1 + continue + if not self.strict and self._is_obs_dtext(ch): + parts.append(ch) + self._i += 1 + continue + raise ValueError("invalid domain-literal content") + raise ValueError("unterminated domain-literal") + + def _parse_quoted_pair(self) -> str: + self._expect("\\") + self._i += 1 + if self._i >= self._len: + raise ValueError("unterminated quoted-pair") + ch = self._peek() + if self.strict: + if not (self._is_vchar(ch) or ch in {" ", "\t"}): + raise ValueError("invalid quoted-pair") + self._i += 1 + return ch + + def _parse_obs_route(self, comments: list[str]) -> None: + if self.strict: + raise ValueError("obsolete route is not permitted in strict mode") + while self._i < self._len and self._peek() != ":": + self._skip_cfws(comments) + if self._peek() == ",": + self._i += 1 + continue + if self._peek() != "@": + raise ValueError("invalid obsolete route") + self._i += 1 + self._skip_cfws(comments) + self._parse_obs_domain(comments) + self._skip_cfws(comments) + if self._peek() == ",": + self._i += 1 + continue + if self._peek() != ":": + raise ValueError("invalid obsolete route") + self._expect(":") + self._i += 1 + self._skip_cfws(comments) + + def _skip_cfws(self, comments: list[str] | None) -> bool: + consumed = False + while True: + progressed = False + while self._i < self._len and self._peek() in {" ", "\t"}: + self._i += 1 + consumed = progressed = True + if self._starts_fws(): + self._consume_fws() + consumed = progressed = True + if self._i < self._len and self._peek() == "(": + if comments is not None: + comments.append(self._parse_comment()) + else: + self._parse_comment() + consumed = progressed = True + continue + if not progressed: + return consumed + + def _parse_comment(self) -> str: + self._expect("(") + self._i += 1 + parts: list[str] = [] + while self._i < self._len: + if self._peek() == ")": + self._i += 1 + return "".join(parts).strip() + if self._starts_fws(): + self._consume_fws() + parts.append(" ") + continue + if self._peek() == "(": + nested = self._parse_comment() + if nested: + parts.append(nested) + continue + if self._peek() == "\\": + parts.append(self._parse_quoted_pair()) + continue + ch = self._peek() + if self._is_ctext(ch): + parts.append(ch) + self._i += 1 + continue + if not self.strict and self._is_obs_ctext(ch): + parts.append(ch) + self._i += 1 + continue + raise ValueError("invalid comment content") + raise ValueError("unterminated comment") + + def _consume_fws(self) -> None: + seen_wsp = False + while self._i < self._len and self._peek() in {" ", "\t"}: + self._i += 1 + seen_wsp = True + if self._i + 1 < self._len and self._raw[self._i] == "\r" and self._raw[self._i + 1] == "\n": + self._i += 2 + while self._i < self._len and self._peek() in {" ", "\t"}: + self._i += 1 + seen_wsp = True + while not self.strict and self._starts_fws(): + self._consume_fws() + seen_wsp = True + if not seen_wsp: + raise ValueError("expected FWS") + + def _starts_fws(self) -> bool: + if self._i >= self._len: + return False + if self._peek() in {" ", "\t"}: + return True + return self._i + 1 < self._len and self._raw[self._i] == "\r" and self._raw[self._i + 1] == "\n" and ( + self._i + 2 < self._len and self._raw[self._i + 2] in {" ", "\t"} + ) + + def _classify_structure(self) -> str: + marks = {"<": None, ":": None, "@": None} + for ch, idx in self._scan_top_level(): + if ch in marks and marks[ch] is None: + marks[ch] = idx + lt = marks["<"] + colon = marks[":"] + at = marks["@"] + if colon is not None and (lt is None or colon < lt) and (at is None or colon < at): + return "group" + if lt is not None and (at is None or lt < at): + return "name-addr" + if at is not None: + return "addr-spec" + return "unknown" + + def _find_top_level(self, target: str) -> int | None: + for ch, idx in self._scan_top_level(): + if ch == target: + return idx + return None + + def _scan_top_level(self) -> Iterable[tuple[str, int]]: + i = self._i + depth = 0 + in_quote = False + in_bracket = False + while i < self._len: + ch = self._raw[i] + if in_quote: + if ch == "\\" and i + 1 < self._len: + i += 2 + continue + if ch == '"': + in_quote = False + i += 1 + continue + if depth > 0: + if ch == "\\" and i + 1 < self._len: + i += 2 + continue + if ch == "(": + depth += 1 + i += 1 + continue + if ch == ")": + depth -= 1 + i += 1 + continue + i += 1 + continue + if in_bracket: + if ch == "\\" and i + 1 < self._len: + i += 2 + continue + if ch == "]": + in_bracket = False + i += 1 + continue + if ch == '"': + in_quote = True + i += 1 + continue + if ch == "(": + depth = 1 + i += 1 + continue + if ch == "[": + in_bracket = True + i += 1 + continue + yield ch, i + i += 1 + + def _validate_domain_literal(self, value: str) -> None: + if not value: + return + candidate = value + if candidate.lower().startswith("ipv6:"): + try: + ipaddress.IPv6Address(candidate[5:]) + except ValueError: + return + return + try: + ipaddress.IPv4Address(candidate) + except ValueError: + return + + def _expect(self, ch: str) -> None: + if self._i >= self._len or self._raw[self._i] != ch: + raise ValueError(f"expected {ch!r}") + + def _expect_eof(self) -> None: + if self._i != self._len: + raise ValueError("unexpected trailing content") + + def _peek(self, offset: int = 0) -> str: + idx = self._i + offset + if idx >= self._len: + return "" + return self._raw[idx] + + def _is_atext_start(self, ch: str) -> bool: + return bool(ch) and self._is_atext(ch) + + def _is_atext(self, ch: str) -> bool: + return ch.isalnum() or ch in "!#$%&'*+-/=?^_`{|}~" + + def _is_qtext(self, ch: str) -> bool: + if not ch: + return False + code = ord(ch) + if ch in {'"', "\\"} or ch in {"\r", "\n"}: + return False + return 33 <= code <= 126 + + def _is_dtext(self, ch: str) -> bool: + if not ch: + return False + code = ord(ch) + if ch in {"[", "]", "\\"} or ch in {"\r", "\n"}: + return False + return 33 <= code <= 90 or 94 <= code <= 126 + + def _is_ctext(self, ch: str) -> bool: + if not ch: + return False + code = ord(ch) + if ch in {"(", ")", "\\"} or ch in {"\r", "\n"}: + return False + return 33 <= code <= 39 or 42 <= code <= 91 or 93 <= code <= 126 + + def _is_obs_ctext(self, ch: str) -> bool: + if not ch: + return False + code = ord(ch) + return code in range(1, 9) or code in {11, 12, 127} or 14 <= code <= 31 + + def _is_obs_dtext(self, ch: str) -> bool: + return self._is_obs_ctext(ch) + + def _is_vchar(self, ch: str) -> bool: + return bool(ch) and 33 <= ord(ch) <= 126 diff --git a/test_parser.py b/test_parser.py new file mode 100644 index 0000000..fe77185 --- /dev/null +++ b/test_parser.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import unittest + +from parser import AddressParser, RFC5322Address + + +def parse(raw: str, *, strict: bool = True) -> RFC5322Address: + return AddressParser(strict=strict).parse(raw) + + +def assert_mailbox( + test_case: unittest.TestCase, + value: RFC5322Address, + *, + display_name: str | None, + local_part: str | None, + domain: str | None, +) -> None: + test_case.assertFalse(value.is_group) + test_case.assertEqual(value.display_name, display_name) + test_case.assertEqual(value.local_part, local_part) + test_case.assertEqual(value.domain, domain) + + +def assert_group( + test_case: unittest.TestCase, + value: RFC5322Address, + *, + display_name: str, + member_count: int, +) -> None: + test_case.assertTrue(value.is_group) + test_case.assertEqual(value.display_name, display_name) + test_case.assertIsNone(value.local_part) + test_case.assertIsNone(value.domain) + test_case.assertEqual(len(value.group_members), member_count) + + +class Test321QuotedPair(unittest.TestCase): + def test_quoted_pair_in_quoted_string_escaped_double_quote(self) -> None: + value = parse('"ab\\"cd"@example.com') + assert_mailbox(self, value, display_name=None, local_part='ab"cd', domain="example.com") + + def test_quoted_pair_in_quoted_string_escaped_backslash(self) -> None: + value = parse('"ab\\\\cd"@example.com') + assert_mailbox(self, value, display_name=None, local_part="ab\\cd", domain="example.com") + + def test_quoted_pair_in_quoted_string_escaped_at_sign(self) -> None: + value = parse('"ab\\@cd"@example.com') + assert_mailbox(self, value, display_name=None, local_part="ab@cd", domain="example.com") + + def test_quoted_pair_in_quoted_string_escaped_space(self) -> None: + value = parse('"a\\ b"@example.com') + assert_mailbox(self, value, display_name=None, local_part="a b", domain="example.com") + + def test_quoted_pair_in_comment(self) -> None: + value = parse(r"John (A nice \) chap) ") + assert_mailbox(self, value, display_name="John", local_part="john", domain="example.com") + self.assertTrue(any(")" in comment for comment in value.comments)) + + +class Test322FWS(unittest.TestCase): + def test_fws_single_spaces_in_display_name(self) -> None: + value = parse("John Doe ") + assert_mailbox(self, value, display_name="John Doe", local_part="john", domain="example.com") + + def test_fws_tab_in_display_name(self) -> None: + value = parse("John\tDoe ") + assert_mailbox(self, value, display_name="John Doe", local_part="john", domain="example.com") + + def test_fws_crlf_in_display_name(self) -> None: + value = parse("John\r\n Doe ") + assert_mailbox(self, value, display_name="John Doe", local_part="john", domain="example.com") + + def test_fws_in_quoted_string_local_part(self) -> None: + value = parse('"John\r\n Doe"@example.com') + assert_mailbox(self, value, display_name=None, local_part="John Doe", domain="example.com") + + def test_fws_in_address_list(self) -> None: + items = AddressParser().parse_address_list("A ,\r\n B ") + self.assertEqual(len(items), 2) + assert_mailbox(self, items[0], display_name="A", local_part="a", domain="b") + assert_mailbox(self, items[1], display_name="B", local_part="c", domain="d") + + +class Test323CFWSComments(unittest.TestCase): + def test_leading_and_trailing_comments_around_addr_spec(self) -> None: + value = parse("(lead)user@example.com(trail)") + assert_mailbox(self, value, display_name=None, local_part="user", domain="example.com") + self.assertTrue(any("lead" in comment for comment in value.comments)) + self.assertTrue(any("trail" in comment for comment in value.comments)) + + def test_comments_between_display_name_words(self) -> None: + value = parse("John (a) Doe ") + assert_mailbox(self, value, display_name="John Doe", local_part="john", domain="example.com") + self.assertTrue(any(comment == "a" for comment in value.comments)) + + def test_nested_comment_text_collected(self) -> None: + value = parse("John (outer (inner) outer) ") + assert_mailbox(self, value, display_name="John", local_part="john", domain="example.com") + self.assertTrue(any("inner" in comment for comment in value.comments)) + + def test_comments_before_angle_addr(self) -> None: + value = parse("(lead) ") + assert_mailbox(self, value, display_name=None, local_part="john", domain="example.com") + self.assertTrue(any("lead" in comment for comment in value.comments)) + + def test_comments_around_at_sign(self) -> None: + value = parse("user (x) @ example.com") + assert_mailbox(self, value, display_name=None, local_part="user", domain="example.com") + self.assertTrue(any(comment == "x" for comment in value.comments)) + + def test_comments_on_domain_side(self) -> None: + value = parse("user @ (x) example.com") + assert_mailbox(self, value, display_name=None, local_part="user", domain="example.com") + self.assertTrue(any(comment == "x" for comment in value.comments)) + + def test_comments_in_group_name(self) -> None: + value = parse("Group (team): member@example.com;") + assert_group(self, value, display_name="Group", member_count=1) + self.assertTrue(any("team" in comment for comment in value.comments)) + + def test_comments_in_address_list_items(self) -> None: + items = AddressParser().parse_address_list("(x) A , (y) B (z)") + self.assertEqual(len(items), 2) + assert_mailbox(self, items[0], display_name="A", local_part="a", domain="b") + assert_mailbox(self, items[1], display_name="B", local_part="c", domain="d") + self.assertTrue(any(comment == "x" for comment in items[0].comments)) + self.assertTrue(any(comment == "y" for comment in items[1].comments)) + self.assertTrue(any("z" in comment for comment in items[1].comments)) + + +class Test324QuotedString(unittest.TestCase): + def test_quoted_string_empty_local_part(self) -> None: + value = parse('""@example.com') + assert_mailbox(self, value, display_name=None, local_part="", domain="example.com") + + def test_quoted_string_with_spaces(self) -> None: + value = parse('"a b"@example.com') + assert_mailbox(self, value, display_name=None, local_part="a b", domain="example.com") + + def test_quoted_string_with_escaped_quote(self) -> None: + value = parse('"a\\"b"@example.com') + assert_mailbox(self, value, display_name=None, local_part='a"b', domain="example.com") + + def test_quoted_string_with_escaped_backslash(self) -> None: + value = parse('"a\\\\b"@example.com') + assert_mailbox(self, value, display_name=None, local_part="a\\b", domain="example.com") + + def test_quoted_string_with_folded_whitespace(self) -> None: + value = parse('"a\r\n b"@example.com') + assert_mailbox(self, value, display_name=None, local_part="a b", domain="example.com") + + def test_quoted_string_with_escaped_comma(self) -> None: + value = parse('"x\\,y"@example.com') + assert_mailbox(self, value, display_name=None, local_part="x,y", domain="example.com") + + def test_quoted_string_as_display_name(self) -> None: + value = parse('"Display Name" ') + assert_mailbox(self, value, display_name="Display Name", local_part="user", domain="example.com") + + def test_quoted_string_with_punctuation_chars(self) -> None: + value = parse('"a.b,c;d"@example.com') + assert_mailbox(self, value, display_name=None, local_part="a.b,c;d", domain="example.com") + + +class Test325MiscTokens(unittest.TestCase): + def test_atom_local_part_with_plus(self) -> None: + value = parse("user+tag@example.com") + assert_mailbox(self, value, display_name=None, local_part="user+tag", domain="example.com") + + def test_dot_atom_domain_with_multiple_labels(self) -> None: + value = parse("user@sub.example.co.uk") + assert_mailbox(self, value, display_name=None, local_part="user", domain="sub.example.co.uk") + + def test_phrase_with_atoms_and_quoted_word(self) -> None: + value = parse('"Jane Q" Public ') + assert_mailbox(self, value, display_name="Jane Q Public", local_part="jane", domain="example.com") + + +class Test34AddressMailboxGroup(unittest.TestCase): + def test_addr_spec_only_mailbox(self) -> None: + value = parse("john@example.com") + assert_mailbox(self, value, display_name=None, local_part="john", domain="example.com") + + def test_name_addr_with_display_name(self) -> None: + value = parse("John Doe ") + assert_mailbox(self, value, display_name="John Doe", local_part="john", domain="example.com") + + def test_angle_addr_without_display_name(self) -> None: + value = parse("") + assert_mailbox(self, value, display_name=None, local_part="john", domain="example.com") + + def test_mailbox_list_two_items(self) -> None: + items = AddressParser().parse_mailbox_list("john@example.com, jane@example.com") + self.assertEqual(len(items), 2) + assert_mailbox(self, items[0], display_name=None, local_part="john", domain="example.com") + assert_mailbox(self, items[1], display_name=None, local_part="jane", domain="example.com") + + def test_address_list_two_name_addrs(self) -> None: + items = AddressParser().parse_address_list("John , Jane ") + self.assertEqual(len(items), 2) + assert_mailbox(self, items[0], display_name="John", local_part="j", domain="a") + assert_mailbox(self, items[1], display_name="Jane", local_part="j", domain="b") + + def test_group_with_two_members(self) -> None: + value = parse("Group: john@example.com, jane@example.com;") + assert_group(self, value, display_name="Group", member_count=2) + assert_mailbox(self, value.group_members[0], display_name=None, local_part="john", domain="example.com") + assert_mailbox(self, value.group_members[1], display_name=None, local_part="jane", domain="example.com") + + def test_empty_group(self) -> None: + value = parse("Group:;") + assert_group(self, value, display_name="Group", member_count=0) + + def test_group_with_comment_only_members(self) -> None: + value = parse("Group:(nobody);") + assert_group(self, value, display_name="Group", member_count=0) + self.assertTrue(any("nobody" in comment for comment in value.comments)) + + def test_group_in_address_list(self) -> None: + items = AddressParser().parse_address_list("Group: a@b; , c@d") + self.assertEqual(len(items), 2) + assert_group(self, items[0], display_name="Group", member_count=1) + assert_mailbox(self, items[1], display_name=None, local_part="c", domain="d") + + def test_mailbox_with_leading_cfws(self) -> None: + value = parse("(x) John ") + assert_mailbox(self, value, display_name="John", local_part="j", domain="a") + self.assertTrue(any("x" in comment for comment in value.comments)) + + def test_mailbox_with_trailing_cfws(self) -> None: + value = parse("John (y)") + assert_mailbox(self, value, display_name="John", local_part="j", domain="a") + self.assertTrue(any("y" in comment for comment in value.comments)) + + def test_address_list_with_comment_wrapped_items(self) -> None: + items = AddressParser().parse_address_list("(x) John , (y) Jane ") + self.assertEqual(len(items), 2) + assert_mailbox(self, items[0], display_name="John", local_part="j", domain="a") + assert_mailbox(self, items[1], display_name="Jane", local_part="j", domain="b") + self.assertTrue(any("x" in comment for comment in items[0].comments)) + self.assertTrue(any("y" in comment for comment in items[1].comments)) + + +class Test341AddrSpecDomainLiteral(unittest.TestCase): + def test_simple_dot_atom_addr_spec(self) -> None: + value = parse("john.doe@example.com") + assert_mailbox(self, value, display_name=None, local_part="john.doe", domain="example.com") + + def test_quoted_local_part_addr_spec(self) -> None: + value = parse('"john.doe"@example.com') + assert_mailbox(self, value, display_name=None, local_part="john.doe", domain="example.com") + + def test_ipv4_domain_literal(self) -> None: + value = parse("john@[127.0.0.1]") + assert_mailbox(self, value, display_name=None, local_part="john", domain="127.0.0.1") + + def test_ipv6_domain_literal(self) -> None: + value = parse("john@[IPv6:2001:db8::1]") + assert_mailbox(self, value, display_name=None, local_part="john", domain="IPv6:2001:db8::1") + + def test_domain_literal_with_plain_text(self) -> None: + value = parse("john@[example-host]") + assert_mailbox(self, value, display_name=None, local_part="john", domain="example-host") + + def test_domain_literal_with_dtext_punctuation(self) -> None: + value = parse("john@[abc!^xyz]") + assert_mailbox(self, value, display_name=None, local_part="john", domain="abc!^xyz") + + def test_domain_literal_with_cfws(self) -> None: + value = parse("user @ [example-host]") + assert_mailbox(self, value, display_name=None, local_part="user", domain="example-host") + + def test_mailbox_list_with_domain_literals(self) -> None: + items = AddressParser().parse_mailbox_list("john@[192.0.2.1], jane@[IPv6:2001:db8::2]") + self.assertEqual(len(items), 2) + assert_mailbox(self, items[0], display_name=None, local_part="john", domain="192.0.2.1") + assert_mailbox(self, items[1], display_name=None, local_part="jane", domain="IPv6:2001:db8::2") + + +class Test44Obsolete(unittest.TestCase): + def test_obs_local_part_with_quoted_word(self) -> None: + parser = AddressParser(strict=False) + value = parser.parse("a.\"b\".c@example.com") + assert_mailbox(self, value, display_name=None, local_part="a.b.c", domain="example.com") + + def test_obs_local_part_with_cfws(self) -> None: + parser = AddressParser(strict=False) + value = parser.parse("john . doe@example.com") + assert_mailbox(self, value, display_name=None, local_part="john.doe", domain="example.com") + + def test_obs_domain_with_cfws(self) -> None: + parser = AddressParser(strict=False) + value = parser.parse("john@example . com") + assert_mailbox(self, value, display_name=None, local_part="john", domain="example.com") + + def test_obs_angle_addr_with_route(self) -> None: + parser = AddressParser(strict=False) + value = parser.parse("John <@a,@b:user@example.com>") + assert_mailbox(self, value, display_name="John", local_part="user", domain="example.com") + + def test_obs_angle_addr_without_display_name(self) -> None: + parser = AddressParser(strict=False) + value = parser.parse("<@a:user@example.com>") + assert_mailbox(self, value, display_name=None, local_part="user", domain="example.com") + + def test_obs_group_list_only_commas(self) -> None: + parser = AddressParser(strict=False) + value = parser.parse("List: , , ;") + assert_group(self, value, display_name="List", member_count=0) + + def test_obs_mailbox_list_with_leading_commas(self) -> None: + parser = AddressParser(strict=False) + items = parser.parse_mailbox_list(",,john@example.com,,") + self.assertEqual(len(items), 1) + assert_mailbox(self, items[0], display_name=None, local_part="john", domain="example.com") + + def test_obs_mailbox_list_with_trailing_commas(self) -> None: + parser = AddressParser(strict=False) + items = parser.parse_mailbox_list("john@example.com,,") + self.assertEqual(len(items), 1) + assert_mailbox(self, items[0], display_name=None, local_part="john", domain="example.com") + + def test_obs_address_list_with_route_and_mailbox(self) -> None: + parser = AddressParser(strict=False) + items = parser.parse_address_list("John <@a:user@example.com>, jane@example.com") + self.assertEqual(len(items), 2) + assert_mailbox(self, items[0], display_name="John", local_part="user", domain="example.com") + assert_mailbox(self, items[1], display_name=None, local_part="jane", domain="example.com") + + def test_obs_group_list_with_comments(self) -> None: + parser = AddressParser(strict=False) + value = parser.parse("List:(x), , ;") + assert_group(self, value, display_name="List", member_count=0) + self.assertTrue(any("x" in comment for comment in value.comments)) + + +class TestEdgeCases(unittest.TestCase): + def test_exact_998_character_input(self) -> None: + local = "a" * 993 + raw = f"{local}@x.co" + self.assertEqual(len(raw), 998) + value = parse(raw) + assert_mailbox(self, value, display_name=None, local_part=local, domain="x.co") + + def test_empty_quoted_string_local_part(self) -> None: + value = parse('""@example.com') + assert_mailbox(self, value, display_name=None, local_part="", domain="example.com") + + def test_empty_domain_literal(self) -> None: + value = parse("user@[]") + assert_mailbox(self, value, display_name=None, local_part="user", domain="") + + def test_source_is_preserved_on_parsed_address(self) -> None: + raw = "John " + value = parse(raw) + self.assertEqual(value.source, raw) + + def test_permissive_empty_address_list_from_cfws_only_input(self) -> None: + items = AddressParser(strict=False).parse_address_list("") + self.assertEqual(items, []) + + +class TestInvalidRejection(unittest.TestCase): + def test_reject_empty_input(self) -> None: + with self.assertRaises(ValueError): + parse("") + + def test_reject_trailing_content(self) -> None: + with self.assertRaises(ValueError): + parse("john@example.com extra") + + def test_reject_double_at(self) -> None: + with self.assertRaises(ValueError): + parse("john@@example.com") + + def test_reject_missing_domain(self) -> None: + with self.assertRaises(ValueError): + parse("john@") + + def test_reject_missing_local_part(self) -> None: + with self.assertRaises(ValueError): + parse("@example.com") + + def test_reject_unterminated_quoted_string(self) -> None: + with self.assertRaises(ValueError): + parse('"unterminated@example.com') + + def test_reject_unterminated_comment(self) -> None: + with self.assertRaises(ValueError): + parse("(unterminated") + + def test_reject_group_missing_semicolon(self) -> None: + with self.assertRaises(ValueError): + parse("Group:john@example.com") + + def test_reject_obsolete_local_part_in_strict_mode(self) -> None: + with self.assertRaises(ValueError): + parse("john . doe@example.com") + + def test_reject_obsolete_domain_in_strict_mode(self) -> None: + with self.assertRaises(ValueError): + parse("john@example . com") + + def test_reject_obsolete_route_in_strict_mode(self) -> None: + with self.assertRaises(ValueError): + parse("John <@a,@b:user@example.com>") + + +if __name__ == "__main__": + unittest.main()