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()