"""
RFC 5322 / MIME parser producing a JMAP Email object (RFC 8621 §4).

Lenient by design: real-world inbound MIME comes from every MTA on the
planet and routinely violates RFC 5322 / 5321 / 2045 / 2046 / 2047 / 2231.
We parse with the stdlib ``compat32`` policy and recover what we can
from malformed Content-Transfer-Encoding, missing charsets, broken
structural delimiters, and obs-syntax address headers. Strict outbound
composition is the composer's job (see ``composer.py`` and ``README.md``
— strict compose, lenient parse).

**Defense posture**: this parser must be hardened standalone. SMTP-in
is not the only feed — EML uploads, mbox / PST / IMAP imports,
forwards, drafts, etc. all reach this code directly without any
SMTP-layer normalization. Anything we want to defend against (bare-LF
end-of-DATA, NUL in headers, MIME bombs, smuggling, header injection)
must be guarded here, not assumed upstream.
"""

import base64
import hashlib
import logging
import re
import unicodedata
from collections import Counter, defaultdict
from datetime import datetime
from datetime import timezone as dt_timezone
from email import policy as email_policy
from email.errors import HeaderParseError, MessageError
from email.feedparser import (
    BufferedSubFile,  # ty: ignore[unresolved-import]
    BytesFeedParser,
)
from email.header import decode_header as _stdlib_decode_header
from email.message import Message
from email.utils import (
    collapse_rfc2231_value,
    getaddresses,
    parsedate_to_datetime,
)
from typing import Any, cast

from .addresses import is_valid_addr_spec
from .filenames import sanitize_filename
from .options import DEFAULT_PARSE_OPTIONS, ParseOptions
from .preview import preview_text
from .types import EmailAddress, EmailBodyPart, JmapEmail

# Resource caps and their provenance live on
# :class:`jmap_email.options.ParseOptions`; per-call overrides go through
# the ``options=`` keyword. Read ``DEFAULT_PARSE_OPTIONS`` where no
# per-call bundle is in scope.
#
# ``message/rfc822`` nesting is implicitly bounded and so has no cap: we
# treat ``message/*`` parts as opaque attachments (no recursion in
# ``_parse_body_structure``), so a hostile chain of nested forwards can
# only hurt us via stdlib's ``Message.as_bytes()`` when we serialize the
# wrapped sub-message in ``_decoded_part_body``. That call catches
# ``RecursionError`` directly.

# Characters stripped from decoded display-names before they are
# surfaced. Header-injection vector: a downstream consumer that re-
# emits the display name (e.g. into an outbound forward / DSN) would
# otherwise smuggle a new header line. Same threat-model class as
# Apache James CVE-2024-21742 / Python CVE-2024-6923 on the compose
# side. Matches the composer's ``_HEADER_INJECTION_CHARS`` list: all
# C0 controls except TAB, plus DEL, NEL, and the U+2028 / U+2029
# Unicode line/paragraph separators.
_NAME_INJECTION_CHARS = (
    "".join(chr(c) for c in range(0x00, 0x20) if c != 0x09) + "\x7f\x85\u2028\u2029"
)
_NAME_INJECTION_TABLE = str.maketrans("", "", _NAME_INJECTION_CHARS)

logger = logging.getLogger(__name__)

# Stdlib ``compat32`` is the lenient end of the policy spectrum: it
# returns raw header strings rather than parsed structured objects, and
# tolerates the malformed input that ``policy.default`` would surface
# as defects-into-exceptions. We pair it with raw-header access (see
# ``parse_email``) so non-ASCII 8-bit headers — surrogate-
# escaped by the BytesFeedParser into the raw store — get reassembled
# back to UTF-8 instead of being mangled into U+FFFD by the public
# ``Message.items()`` view.
_PARSE_POLICY = email_policy.compat32


# ─── Nesting-depth cost ───
#
# ``BufferedSubFile.readline`` tests every body line against every
# ancestor predicate, which makes the stdlib parse O(depth × lines) —
# enough for one legal-but-deep message to occupy a worker.
# ``max_mime_nesting_depth`` could not help: it is applied during the
# body-tree walk, by which point the parse has already been paid for.
#
# RFC 2046 §5.1.1 requires a *boundary* delimiter to begin with ``--``,
# so while every active predicate is a boundary matcher the scan can be
# skipped for any other line. That makes cost flat in depth with
# identical output. The stack is not boundary-only, though — see the
# class — so this is not an optimisation CPython simply missed.


class _FastSubFile(BufferedSubFile):
    """``BufferedSubFile`` without the per-ancestor scan on body lines.

    The scan may only be skipped while every *active* predicate requires
    a ``--`` prefix. ``_eofstack`` is not boundary-only: inside a
    ``message/delivery-status`` the parser pushes ``NLCRE.match``, for
    which a *blank* line is the false EOF. Skipping that merges the DSN's
    per-message and per-recipient blocks into one and turns
    ``Final-Recipient`` / ``Status`` into an unparsed payload.

    So the predicates are tracked as they are pushed, and anything not
    recognised as a boundary matcher disables the shortcut for as long
    as it is on the stack — unrecognised means slow, never wrong.
    """

    def __init__(self):
        super().__init__()
        self._delimiter_only = True

    def _refresh(self):
        self._delimiter_only = all(
            getattr(pred, "__name__", "") == "boundarymatch" for pred in self._eofstack
        )

    def push_eof_matcher(self, pred):
        super().push_eof_matcher(pred)
        self._refresh()

    def pop_eof_matcher(self):
        pred = super().pop_eof_matcher()
        self._refresh()
        return pred

    def readline(self):
        lines = self._lines
        if lines and self._eofstack and self._delimiter_only and lines[0][:2] != "--":
            return lines.popleft()
        return super().readline()


def _message_from_bytes(raw: bytes) -> Message:
    """``email.message_from_bytes`` with the faster input buffer.

    Substituting ``_input`` is the whole integration: the parser is
    otherwise the stdlib's, fed in one shot, and the result is
    byte-identical. ``_probe_fast_subfile`` verifies the substitution
    still takes effect rather than assuming it.
    """
    feed_parser = BytesFeedParser(policy=_PARSE_POLICY)
    feed_parser._input = _FastSubFile()  # ty: ignore[unresolved-attribute]  # noqa: SLF001
    feed_parser.feed(raw)
    return feed_parser.close()


def _probe_fast_subfile() -> bool:
    """Verify the substituted buffer is actually used by the parser.

    ``_input`` is private, so this pins the coupling to a startup check
    instead of a silent no-op. ``tests/test_parser.py`` asserts the
    result, so a Python upgrade that moves it fails CI.
    """
    try:
        probe = BytesFeedParser(policy=_PARSE_POLICY)
        probe._input = _FastSubFile()  # ty: ignore[unresolved-attribute]  # noqa: SLF001
        probe.feed(b'Content-Type: multipart/mixed; boundary="B"\n\n--B\n\nx\n--B--\n')
        used = isinstance(probe._input, _FastSubFile)  # ty: ignore[unresolved-attribute]  # noqa: SLF001
        return used and probe.close().is_multipart()
    except Exception:  # pylint: disable=broad-exception-caught
        return False


_FAST_SUBFILE_SUPPORTED = _probe_fast_subfile()

if not _FAST_SUBFILE_SUPPORTED:  # pragma: no cover
    logger.error(
        "The faster MIME input buffer is not taking effect on this Python; "
        "deeply nested messages will parse slowly."
    )


def _strip_nul_bytes(text: str) -> str:
    """Strip NUL bytes from text.

    PostgreSQL text fields cannot store NUL (0x00) bytes.
    This char is used to mark the end of a string in C language
    and is not valid in PostgreSQL text fields. Furthermore the
    RFC 5322 section 4 defines it as an obsolete character.
    https://datatracker.ietf.org/doc/html/rfc5322#page-31
    """
    return text.replace("\x00", "") if text else ""


def _repair_surrogate_escaped(text: str) -> str:
    """Repair raw 8-bit header bytes that stdlib decoded via surrogateescape.

    The stdlib ``compat32`` parser stores header bytes that fall outside
    ASCII as Python "lone surrogate" code points (U+DC80..U+DCFF) — the
    standard ``surrogateescape`` error-handler shape. The raw byte
    sequence is recoverable, and most real-world senders that put raw
    8-bit in headers use UTF-8 (e.g. Gmail Takeout's ``X-Gmail-Labels``).
    Round-trip bytes → UTF-8 when possible, fall back to latin-1 so we
    never lose data.
    """
    if not any(0xDC80 <= ord(c) <= 0xDCFF for c in text):
        return text
    try:
        as_bytes = text.encode("utf-8", errors="surrogateescape")
    except UnicodeEncodeError:
        return text
    try:
        return as_bytes.decode("utf-8")
    except UnicodeDecodeError:
        try:
            return as_bytes.decode("latin-1")
        except UnicodeDecodeError:
            return text


def decode_rfc2047_header(header_text: str) -> str:
    """Decode RFC 2047 encoded-words in a header value to a single string.

    Wraps :func:`email.header.decode_header` (stdlib) with four
    additional guarantees the bare stdlib helper doesn't give:
    a single string return type (not a list of fragments); recovery
    from ``HeaderParseError`` on malformed base64 inside an encoded-
    word so a single bad ``=?…?b?…?=`` doesn't torpedo the rest of
    the parse; surrogate-escape repair of raw 8-bit bytes left
    by the ``compat32`` policy; and a bound on the input.

    The bound is required: ``decode_header`` pops from the front of a
    list, making it O(n²) in the number of encoded-words. ``parse_email``
    rejects an over-long header value outright, but that check runs over
    ``message.raw_items()`` — the top-level fields only. A MIME *part*
    header is never seen by it, so an attachment filename off a
    sub-part's ``Content-Disposition`` reaches here unbounded and this is
    the only thing standing in front of it.

    A work bound, not a policy limit: it borrows the magnitude of
    ``max_header_value_bytes`` but counts characters, which is what the
    fragment count — and so the cost — actually tracks. Deliberately not
    a ``ParseOptions`` knob: the call site that needs it
    (``_get_part_info``) has no options to thread, and a parameter no
    internal caller can honour is worse than none.

    Folding CRLF+WSP is unfolded to single spaces; other internal
    whitespace runs are preserved.
    """
    if not header_text:
        return ""

    header_text_str = str(header_text)
    cap = DEFAULT_PARSE_OPTIONS.max_header_value_bytes
    if len(header_text_str) > cap:
        logger.warning(
            "decode_rfc2047_header: input exceeds %d characters; truncating", cap
        )
        header_text_str = header_text_str[:cap]
    # Stdlib's ``email.header.decode_header`` returns a list of
    # ``(decoded_string, charset)`` pairs (charset is ``None`` when the
    # fragment was not encoded). It raises ``HeaderParseError`` on
    # malformed base64 inside an encoded-word — recover by passing the
    # raw text through with surrogate-repair so the rest of the header
    # survives.
    try:
        decoded_parts = _stdlib_decode_header(header_text_str)
    except HeaderParseError:
        return _repair_surrogate_escaped(header_text_str)

    result_parts = []
    for part, charset in decoded_parts:
        if isinstance(part, bytes):
            # Decode bytes using charset or fallbacks
            if not charset or charset == "unknown-8bit":
                try:
                    result_parts.append(part.decode("utf-8", errors="replace"))
                except UnicodeDecodeError:
                    result_parts.append(part.decode("latin-1", errors="replace"))
            else:
                try:
                    result_parts.append(part.decode(charset, errors="replace"))
                except (LookupError, UnicodeDecodeError):
                    result_parts.append(part.decode("utf-8", errors="replace"))
        else:
            # Part is already a string. Repair surrogate-escaped 8-bit
            # (see ``_repair_surrogate_escaped``).
            result_parts.append(_repair_surrogate_escaped(part))

    # Join the decoded parts. Unfold CRLF+WSP per RFC 5322 §2.2.3.
    # Internal multi-space / TAB runs are preserved so callers needing
    # spec-precise text get it; the header-injection defense (stripping
    # U+2028 / U+2029 / NEL / VT / FF) lives in ``_clean_address_pair``
    # rather than this generic decoder.
    full_result = "".join(result_parts)
    return re.sub(r"\r?\n[ \t]+", " ", full_result)


def _strip_cfws(value: str) -> str:
    """Strip RFC 5322 CFWS (comments + folding white space) from ``value``.

    Comments are ``(...)`` blocks that may nest per RFC 5322 §3.2.3.
    We walk the string tracking paren depth and drop any character at
    depth >= 1 along with the parens themselves. Folding white space
    (CRLF + WSP, plus the post-encoded-word collapse of consecutive
    whitespace) is reduced to a single space.

    Used to normalise ``messageId`` / ``inReplyTo`` / ``references``
    (RFC 8621 §4.1.2.1 "Comments and/or folding white space (CFWS) and
    surrounding angle brackets are removed") and ``Content-ID``
    (same requirement, §4.1.4).
    """
    if not value:
        return ""
    out: list[str] = []
    depth = 0
    i = 0
    n = len(value)
    while i < n:
        ch = value[i]
        if ch == "\\" and i + 1 < n and depth >= 1:
            # quoted-pair inside a comment — skip the backslash and the
            # escaped char so the consumed content stays inside the
            # comment (it would otherwise leak ``)``s out).
            i += 2
            continue
        if ch == "(":
            depth += 1
            i += 1
            continue
        if ch == ")" and depth >= 1:
            depth -= 1
            i += 1
            continue
        if depth >= 1:
            i += 1
            continue
        out.append(ch)
        i += 1
    # Collapse folding WSP (any run of whitespace) to a single SP, then
    # strip leading/trailing.
    return re.sub(r"\s+", " ", "".join(out)).strip()


def _strip_name_quotes(name: str) -> str:
    """
    Strip surrounding single quotes from display names.

    RFC 5322 uses double quotes for display names with special characters,
    and the parser correctly strips those. However, some email clients
    incorrectly use single quotes, which the parser preserves. We strip
    them for consistency.

    Each end is handled independently: when the parser has already consumed
    one side of the pair — e.g. a leading ``';`` that ``getaddresses``
    splits off a group-looking name, leaving a dangling closing quote — the
    surviving quote is still stripped rather than leaked into the name.
    Only the outermost quote at each end is removed, so internal apostrophes
    survive (``O'Brien``, ``John's``).

    Examples:
        "'John Doe'" -> "John Doe"
        "John Doe" -> "John Doe"
        "'John's Name'" -> "John's Name" (only strips surrounding quotes)
        "mangled'" -> "mangled" (dangling quote from a mis-split parse)
    """
    if name.startswith("'"):
        name = name[1:]
    if name.endswith("'"):
        name = name[:-1]
    return name


def _contains_group_syntax(address_str: str) -> bool:
    """
    Check if the address string contains RFC 5322 group syntax or malformed variants.

    Group syntax format: "Group Name: addr1, addr2;" or "undisclosed-recipients:;"
    Also handles malformed variants like "undisclosed-recipients:>" (using > instead of ;)
    Returns True if any group-like syntax pattern is found.

    The pattern is: word(s) followed by : then addresses/empty then ; or >
    Key insight: the group name comes AFTER any comma separator, so we look for
    patterns like "name:...;" where "name" doesn't contain @.
    """
    stripped = address_str.strip()
    # Check for proper group syntax (;) or malformed variant (>)
    if ";" not in stripped and ":>" not in stripped:
        return False

    # non-@ chars (the group name) followed by ``:`` then content then ``;``/``>``.
    # Handles "undisclosed-recipients:;", "Group: addr1, addr2;", ":;", ":>".
    # The name is anchored to string-start or just-after-a-comma (its documented
    # position) — WITHOUT this anchor the leading ``[^@:,]*`` re-scans from every
    # offset on input with a trailing ``;`` but no matching ``:``, which is
    # quadratic (ReDoS). The ``(?<=,)`` is zero-width so nothing is consumed.
    group_pattern = re.compile(r"(?:^|(?<=,))[^@:,]*:([^;]*;|>)")
    return bool(group_pattern.search(stripped))


def _remove_group_syntax(address_str: str) -> str:
    """
    Remove RFC 5322 group syntax from address string, extracting inner addresses.

    "Group: addr1, addr2;" -> "addr1, addr2"
    "undisclosed-recipients:;" -> ""
    "user@a.com, Group: b@c.com;" -> "user@a.com, b@c.com"
    "user@a.com, undisclosed-recipients:;" -> "user@a.com"
    "undisclosed-recipients:>" -> "" (malformed variant)
    """
    stripped = address_str.strip()
    if ";" not in stripped and ":>" not in stripped:
        return stripped

    # Use regex to find and process group patterns
    # Group pattern: optional word(s) without @ or : or ,, followed by :, then content, then ;
    # Also handle malformed :> variant (empty group with > instead of ;)
    # We replace "GroupName: content;" with just "content". Anchor the group
    # name to string-start / just-after-a-comma (zero-width, so the ``.sub``
    # doesn't eat the separator) — the unanchored ``[^@:,]*`` prefix is
    # quadratic on hostile input (see ``_contains_group_syntax``).
    group_pattern = re.compile(r"(?:^|(?<=,))[^@:,]*:([^;]*);")

    def replace_group(match):
        inner = match.group(1).strip()
        return inner if inner else ""

    result = group_pattern.sub(replace_group, stripped)

    # Handle malformed ":>" pattern (remove "name:>" as an empty malformed
    # group). Same anchoring as above to keep the prefix scan linear.
    malformed_pattern = re.compile(r"(?:^|(?<=,))[^@:,]*:>")
    result = malformed_pattern.sub("", result)

    # Clean up: remove empty entries, extra commas, whitespace
    parts = [p.strip() for p in result.split(",") if p.strip()]
    return ", ".join(parts)


def _clean_address_pair(name: str, addr: str) -> tuple[str, str]:
    """Post-process a single (name, addr) tuple from ``getaddresses``.

    - Decode RFC 2047 encoded-words in the display name (idempotent on
      already-decoded text).
    - Strip stray single quotes (some clients quote display names with
      ``'…'`` instead of ``"…"``).
    - Strip header-injection chars (CR/LF/NUL/NEL/U+2028/U+2029 and
      other C0 controls except TAB) from the decoded display name. A
      downstream consumer re-emitting the name into an outbound header
      would otherwise smuggle a new header line. Same threat-model
      class as Apache James CVE-2024-21742 / CPython CVE-2024-6923.
    """
    name_decoded = _strip_name_quotes(decode_rfc2047_header(name or ""))
    name_safe = name_decoded.translate(_NAME_INJECTION_TABLE)
    return name_safe, addr or ""


def _is_plausible_addr(addr: str) -> bool:
    """Cheap shape-validity check for an addr-spec.

    Defends against two real attacks where stdlib's lenient parser
    surfaces something with an ``@`` that *looks* like an address but
    is actually structural-character residue:

    1. **Encoded-word residue.** A broken encoded-word that span-folds
       across a CRLF (gh-114906) leaves trailing ``?=`` in whatever
       follows. ``=?utf-8?q?safe\\nBcc:_leak@evil.com?=`` makes stdlib
       surface a ``Bcc`` header whose value is ``_leak@evil.com?=`` —
       it has ``@``, but the trailing ``?=`` betrays the residue.
       Downstream code routing on the parsed bcc list would otherwise
       attempt to send to a fake recipient.

    2. **CR/LF / whitespace** inside the addr. RFC 5322 §3.4.1 forbids
       FWS inside addr-spec; if it appears, stdlib's lenient mode
       split somewhere unexpected.
    """
    if not addr or "@" not in addr:
        return False
    if "?=" in addr or "=?" in addr:
        return False  # encoded-word residue
    # CR/LF/HT/NUL inside addr-spec are all RFC 5322 §3.4.1 violations
    # AND header-injection vectors (Mailsploit-class NUL truncation; the
    # composer's _sanitize_header_value strips on the way out but we
    # also defend at the input boundary so downstream consumers — DB
    # inserts, log lines, JSON serialisers — never see them).
    if any(c in addr for c in ("\r", "\n", "\t", "\x00")):
        return False
    # 3. **Not one mailbox.** ``a@b.co, c@d.co`` and ``a b@c.co`` both
    #    carry an ``@`` and no control characters, but neither is a
    #    single addr-spec: the comma is the mailbox-list separator and
    #    the space separates a display name from an angle-addr, so a
    #    consumer re-emitting either into a header gains a recipient it
    #    never validated. Same shape as MimeKit's CVE-2026-30227.
    #    Delegated to the composer's predicate so the parse and compose
    #    sides cannot drift apart on what counts as an address.
    return is_valid_addr_spec(addr)


# An angle-addr sitting inside a display name means the split went wrong.
# The local half excludes ``@`` so the split is unique; both halves
# matching it is quadratic on a ``<`` + run of ``@``.
_ANGLE_ADDR_RE = re.compile(r"<[^<>@]*@[^<>]*>")


def _has_unclosed_comment(value: str) -> bool:
    """True when *value* opens an RFC 5322 comment it never closes.

    Parens inside a quoted-string are literal text and a quoted-pair
    escapes whatever follows it, so both are skipped.
    """
    depth = 0
    in_quotes = False
    index = 0
    while index < len(value):
        char = value[index]
        if char == "\\":
            index += 2
            continue
        if char == '"':
            in_quotes = not in_quotes
        elif not in_quotes:
            if char == "(":
                depth += 1
            elif char == ")" and depth:
                depth -= 1
        index += 1
    return depth > 0


def _comment_ate_the_angle_addr(source: str, name: str) -> bool:
    """True when an unclosed comment swallowed the real addr-spec.

    ``getaddresses(strict=False)`` treats an unclosed ``(`` as a comment
    running to end of input. In ``victim@bank.com( <attacker@evil.co>``
    that comment eats the angle-addr, so the splitter reports a single
    tuple whose *address* is the text the sender typed in display-name
    position and whose *name* holds the mailbox they actually own.

    That is CVE-2023-27043 reached by another route:
    :func:`_pick_best_address` defends the multi-tuple form by taking
    the last plausible tuple, but here the bogus tuple is the only one
    there is. A consumer keying allow/deny, DMARC alignment or contact
    identity off the parsed address would attribute the message to an
    address the sender merely named. Both conditions are required so
    that a quoted display name legitimately containing an angle-addr
    (``"Bob <bob@old.co>" <bob@new.co>``) still parses.
    """
    return bool(_ANGLE_ADDR_RE.search(name)) and _has_unclosed_comment(source)


def _pick_best_address(parsed) -> tuple[str, str] | None:
    """Pick the most-likely-intended address from a getaddresses result.

    Stdlib's lenient splitter emits multiple ``(name, addr)`` tuples
    when the input is ambiguous — most notoriously for CVE-2023-27043-
    style inputs like ``"foo@evil.com" <real@you.com>`` where the
    quoted display-name itself resembles an addr-spec. The first tuple
    is the *display-name-as-addr-spec*, not the angle-addr; allow/deny
    logic that trusts ``parsed[0]`` would route mail to ``foo@evil.com``
    instead of ``real@you.com``.

    Prefer the LAST tuple with a plausible address — it corresponds
    to the trailing angle-addr in the wire input, which is the
    authoritative addr-spec under RFC 5322 §3.4.
    """
    candidate: tuple[str, str] | None = None
    for raw_name, raw_addr in parsed:
        if _is_plausible_addr(raw_addr):
            candidate = (raw_name, raw_addr)
    return candidate


def parse_address(
    address_str: str,
    *,
    lenient: bool = False,
) -> tuple[str, str]:
    """Parse an email address that might include a display name.

    Strict by default. The return value is always either:

    - ``(name, addr)`` where ``addr`` passes the shape check (contains
      ``@``, no encoded-word residue, no CR/LF/TAB/NUL); or
    - ``("", "")`` when the input cannot be parsed into a valid
      addr-spec.

    Pass ``lenient=True`` to fall back to ``("", address_str.strip())``
    when the addr-spec check fails — the right choice for
    archive-import paths that must preserve the original wire bytes
    even when they're invalid (PST / mbox / EML reconstruction). Do
    NOT use lenient mode for entry-point validation (CLI flags, web
    form input): under ``lenient=True``, ``parse_address("no-at")``
    returns ``("", "no-at")``, which silently makes garbage look like
    a valid address.

    Security note: this function deliberately does NOT pre-decode RFC
    2047 encoded-words on the full input — the display-name is decoded
    *after* the address is split out. Pre-decoding would let a sender
    smuggle structural characters (``@``, ``<``, ``>``, ``,``) into the
    addr-spec via an encoded-word in the local-part (the PortSwigger
    "Splitting the Email Atom" technique, DEF CON 32 2024). Callers
    should pass the raw (surrogate-repaired) header value, not a value
    already run through ``decode_rfc2047_header``.

    Args:
        address_str: String containing an email address, possibly with
            a display name.
        lenient: When ``True``, return the input as the ``email`` field
            on parse failure instead of ``("", "")``. Default ``False``.

    Returns:
        Tuple of ``(display_name, email_address)``.

    Examples:
        >>> parse_address('user@example.com')
        ('', 'user@example.com')
        >>> parse_address('User <user@example.com>')
        ('User', 'user@example.com')
        >>> parse_address('no-at-sign')
        ('', '')
        >>> parse_address('no-at-sign', lenient=True)
        ('', 'no-at-sign')
    """
    if not address_str:
        return "", ""

    # Repair raw 8-bit (surrogate-escaped) bytes so getaddresses sees
    # real Unicode chars. We deliberately stop short of full RFC 2047
    # decoding — see the security note in the docstring.
    address_str = _repair_surrogate_escaped(address_str)

    # Handle RFC 5322 group syntax (e.g., "undisclosed-recipients:;")
    # These should return empty for single address parsing.
    if _contains_group_syntax(address_str):
        return "", ""

    # ``strict=False`` makes getaddresses tolerate the malformed and
    # obs-syntax inputs we routinely see in real inbound mail.
    # Defensive guard: stdlib's ``_parseaddr.getcomment`` recurses
    # into nested ``(...)`` blocks without bound (CVE-2002-1337
    # Sendmail-crackaddr shape on input alone). 10k-deep nested
    # comments blow Python's recursion limit. Catch and degrade
    # rather than letting RecursionError propagate.
    try:
        parsed = getaddresses([address_str], strict=False)
    except RecursionError:
        logger.warning("RecursionError in getaddresses; returning empty result")
        return "", ""

    if not parsed:
        return ("", address_str.strip()) if lenient else ("", "")

    best = _pick_best_address(parsed)
    if best is None:
        return ("", address_str.strip()) if lenient else ("", "")

    name, addr = _clean_address_pair(*best)
    if _comment_ate_the_angle_addr(address_str, name):
        return ("", address_str.strip()) if lenient else ("", "")
    return name, addr


def _last_separator_index(value: str, limit: int) -> int:
    """Index of the last mailbox-list ``,`` at or before *limit*, or -1.

    Only a top-level comma separates mailboxes: one inside a
    quoted-string, a domain-literal or a comment is literal text.
    Cutting at those leaves a fragment that re-parses into an address
    nobody sent — ``a@[1,2]`` cut at its comma yields ``a@[1]``.
    """
    in_quote = False
    in_literal = False
    comment_depth = 0
    last = -1
    index = 0
    end = min(len(value), limit)
    while index < end:
        char = value[index]
        if char == "\\" and (in_quote or comment_depth):
            index += 2  # quoted-pair escapes whatever follows
            continue
        if in_quote:
            in_quote = char != '"'
        elif in_literal:
            in_literal = char != "]"
        elif comment_depth:
            if char == "(":
                comment_depth += 1
            elif char == ")":
                comment_depth -= 1
        elif char == '"':
            in_quote = True
        elif char == "[":
            in_literal = True
        elif char == "(":
            comment_depth = 1
        elif char == ",":
            last = index
        index += 1
    return last


def parse_addresses(
    addresses_str: str,
    *,
    options: ParseOptions = DEFAULT_PARSE_OPTIONS,
) -> list[tuple[str, str]]:
    """
    Parse multiple email addresses from a comma-separated string.

    Handles RFC 5322 group syntax (e.g., "Group: addr1, addr2;") by
    extracting the addresses within groups.

    Strict on each entry: tuples whose addr-spec fails the shape check
    (no ``@``, encoded-word residue, embedded CR/LF, etc.) are
    silently dropped — callers comparing the returned length to the
    input's comma count will see a mismatch. There is no ``lenient=``
    knob on this entry point: a multi-address header always wants
    address-tuple recovery, never "the whole header was garbage so
    return it as a single fake address."

    A value over ``max_address_list_bytes`` is cut back to a mailbox
    separator, so entries past the cut are missing from the result. Use
    :func:`_parse_addresses_ex` when you need to know that happened;
    ``parse_email`` does, and reports ``AddressListTruncatedDefect``.

    Args:
        addresses_str: Comma-separated string of email addresses.
        options: Per-call resource caps. See :class:`ParseOptions`. Pass
            a custom instance to widen / tighten the address-list byte
            cap independently of any other parse call in the process.

    Returns:
        List of tuples, each containing (display_name, email_address).
        Entries that fail the addr-spec shape check are omitted.
    """
    return _parse_addresses_ex(addresses_str, options)[0]


def _parse_addresses_ex(
    addresses_str: str, options: ParseOptions
) -> tuple[list[tuple[str, str]], bool]:
    """:func:`parse_addresses`, also reporting whether the input was cut.

    Truncation is a property of the parse, so it is decided here rather
    than re-derived by the caller — two copies of the cut condition would
    drift the moment one changed.
    """
    if not addresses_str:
        return [], False

    # Defensive byte cap. ``getaddresses`` is O(n) but a 50 MB
    # ``To:`` would allocate millions of tuples (see Dovecot
    # CVE-2024-23184 — same anti-pattern in C). The default 100 KB cap
    # holds ~5_000 typical addresses; well above any legitimate
    # mailing-list expansion that lands in a single header.
    # Cut back to the last mailbox separator so the result is always a
    # subset of what an uncapped parse returns. Slicing at the byte bound
    # instead lands mid-token and can *manufacture* an address: pad a
    # header so the cut falls after ``, ceo@corp.example`` in
    # ``…, ceo@corp.example-junk@attacker.test`` and the list parses to
    # exactly ``ceo@corp.example``, which nobody sent. Same shape as
    # Postfix's ``header_address_token_limit``, which discards excess
    # *tokens* rather than bytes.
    cap = options.max_address_list_bytes
    truncated = len(addresses_str) > cap
    if truncated:
        logger.warning("Address-list header exceeds %d bytes; truncating", cap)
        separator = _last_separator_index(addresses_str, cap)
        addresses_str = addresses_str[:separator] if separator >= 0 else ""

    # Repair raw 8-bit (surrogate-escaped) bytes. See
    # ``parse_address`` for why we deliberately stop short of
    # full RFC 2047 decoding here.
    addresses_str = _repair_surrogate_escaped(addresses_str)

    # Handle RFC 5322 group syntax (e.g., "undisclosed-recipients:;" or "Group: a@b.com;")
    # Extract addresses from within groups to avoid parser warnings.
    if _contains_group_syntax(addresses_str):
        addresses_str = _remove_group_syntax(addresses_str)
        if not addresses_str:
            return [], truncated  # Empty group like "undisclosed-recipients:;"

    try:
        parsed = getaddresses([addresses_str], strict=False)
    except RecursionError:
        # Defense vs CVE-2002-1337 Sendmail-crackaddr-shape inputs:
        # stdlib's ``_parseaddr.getcomment`` recurses without bound
        # on nested ``(...)`` comments and can blow Python's recursion
        # limit. Degrade to empty rather than letting the error
        # propagate.
        logger.warning("RecursionError in getaddresses; returning empty list")
        return [], truncated

    result: list[tuple[str, str]] = []
    for raw_name, raw_addr in parsed:
        # ``_is_plausible_addr`` covers the @-check and additionally
        # rejects encoded-word residue / FWS-tainted addresses (see
        # the docstring there for the threat model).
        if not _is_plausible_addr(raw_addr):
            continue
        name, addr = _clean_address_pair(raw_name, raw_addr)
        if _comment_ate_the_angle_addr(addresses_str, name):
            continue
        result.append((name, addr))
    return result, truncated


def parse_date(date_str: str) -> datetime | None:
    """
    Parse date string from email header.

    Args:
        date_str: Date string in RFC 5322 format

    Returns:
        Datetime object or None if parsing fails
    """
    if not date_str:
        return None

    try:
        # Use email.utils which handles RFC 5322 date formats
        return parsedate_to_datetime(date_str)
    except (TypeError, ValueError) as e:  # Catch specific errors
        logger.warning("Could not parse date string '%s': %s", date_str, e)
        return None


# Transfer encodings RFC 2045 §6.1 defines. Anything else on the wire is
# a value some parser in the chain will interpret and another won't.
_KNOWN_TRANSFER_ENCODINGS = frozenset(
    {"7bit", "8bit", "binary", "base64", "quoted-printable"}
)


# RFC 5322 §3.6: each of these may appear at most once. Real senders
# emit duplicates anyway, and which copy a reader sees is parser-defined.
_RFC5322_SINGLETON_HEADERS = frozenset(
    {
        "date",
        "sender",
        "reply-to",
        "to",
        "cc",
        "bcc",
        "message-id",
        "in-reply-to",
        "references",
        "subject",
    }
)


def _flatten_param(value: Any) -> str | None:
    """Reduce a ``get_param`` result to a plain string.

    RFC 2231-encoded parameters come back as a ``(charset, lang, value)``
    tuple; everything else is already a string.
    """
    if value is None:
        return None
    if isinstance(value, tuple):
        # ``(charset, lang, value)`` — the value is percent-decoded bytes
        # carried in a latin-1 str, so it needs the charset applied. Doing
        # ``value[2]`` alone turns "documenté.pdf" into "documentÃ©.pdf".
        try:
            value = collapse_rfc2231_value(value)
        except Exception:  # pylint: disable=broad-exception-caught
            value = value[-1]
    text = str(value).strip()
    return text or None


def _preferred_filename(part: Message) -> str | None:
    """Return the filename a spec-following recipient would use.

    RFC 6266 §4.3: when a ``Content-Disposition`` carries both
    ``filename`` and the RFC 2231 extended ``filename*``, recipients
    SHOULD pick ``filename*`` and ignore ``filename``. The stdlib's
    ``get_filename`` returns whichever the parameter dict happened to
    keep — in practice the plain one — so a sender can show
    ``safe.txt`` to us and ``evil.exe`` to every client that follows the
    RFC. Prefer the extended form, then fall back to the stdlib
    (which also covers the ``Content-Type: name=`` case).
    """
    try:
        params = part.get_params(header="content-disposition") or []
    except Exception:  # pylint: disable=broad-exception-caught
        return part.get_filename()
    for key, value in params:
        if key == "filename" and isinstance(value, tuple):
            return _flatten_param(value)
    return part.get_filename()


# Headers whose value steers how the body is carved up or named. A
# control character in one of these is a structural risk, not a display
# quirk, which is why the check is scoped to them rather than every header.
_MIME_RELEVANT_HEADERS = frozenset(
    {
        "content-type",
        "content-disposition",
        "content-transfer-encoding",
        "content-id",
    }
)


def _iter_mime_header_values(part: Message):
    """Yield ``(name, raw_value)`` for the MIME-structural headers."""
    try:
        items = list(part.items())
    except Exception:  # pylint: disable=broad-exception-caught
        return
    for name, value in items:
        if str(name).lower() in _MIME_RELEVANT_HEADERS:
            yield str(name), str(value)


# Content types whose payload this parser reports as one opaque
# attachment and does not look inside. ``message/rfc822`` is deliberately
# absent: it fires on every forwarded message, so it would be noise
# rather than signal (the README says as much). These two are not
# ordinary mail —
#
#   message/partial       RFC 2046 §5.2.2 splits one message across
#                         several, so the payload only exists after a
#                         reassembly a per-message scanner never does.
#   message/external-body the content is fetched from elsewhere and is
#                         not in this message at all.
#
# — and both are long-standing ways to put something in front of a reader
# that no content scanner ever saw.
_OPAQUE_MESSAGE_DEFECTS = {
    "message/partial": "PartialMessageDefect",
    "message/external-body": "ExternalBodyDefect",
}


def _has_fold_inside_quotes(raw_value: str) -> bool:
    """True when a header folds *inside* a quoted parameter value.

    Folding between parameters is ordinary. Folding inside the quotes is
    where readers diverge: unfolding per RFC 5322 §2.2.3 removes the CRLF
    and keeps the WSP, so ``filename="pay<CRLF> load.exe"`` is
    ``pay load.exe`` here — but a parser that drops the whole fold reads
    ``payload.exe`` and one that truncates at the CR reads ``pay``. Three
    names for one attachment, which is the point of doing it.
    """
    in_quotes = False
    index = 0
    while index < len(raw_value):
        char = raw_value[index]
        if char == "\\" and in_quotes:
            index += 2
            continue
        if char == '"':
            in_quotes = not in_quotes
        elif in_quotes and char in "\r\n":
            return True
        index += 1
    return False


def _collect_message_ambiguity_defects(message: Message, defects: list[str]) -> None:
    """Root-level ambiguities: duplicated singleton headers, missing
    ``MIME-Version``.

    Kept separate from the per-part walk because these are properties of
    the message as a whole — a nested ``message/rfc822`` legitimately
    carries its own ``From``, and flagging that would be noise.
    """
    try:
        names = [str(k).lower() for k in message.keys()]
    except Exception:  # pylint: disable=broad-exception-caught
        return
    counts = Counter(names)

    # Called out separately from the rest: a second ``From`` is how a
    # sender shows one identity to the filter that authenticated the
    # message and another to the human who reads it (CERT VU#517845;
    # "Weak Links in Authentication Chains", USENIX 2020).
    if counts.get("from", 0) > 1:
        defects.append("DuplicateFromDefect")
    if any(counts.get(h, 0) > 1 for h in _RFC5322_SINGLETON_HEADERS):
        defects.append("DuplicateScalarHeaderDefect")

    # MIME syntax with no MIME-Version to license it. We parse it; a
    # strict receiver treats the body as flat text and never sees the
    # parts — including their attachments.
    if not counts.get("mime-version"):
        try:
            is_mime = message.get_content_maintype() == "multipart" or bool(
                message.get("content-transfer-encoding")
            )
        except Exception:  # pylint: disable=broad-exception-caught
            return
        if is_mime:
            defects.append("MissingMimeVersionDefect")


def _collect_ambiguity_defects(part: Message, defects: list[str]) -> None:
    """Record MIME constructs that different parsers resolve differently.

    None of these stop us producing output — we resolve each the way the
    stdlib does — but each is a point where a spam filter, a virus
    scanner and a mail client can legitimately disagree about what the
    message *contains*. Research on MIME parser differentials ("Email
    Smuggling with Differential Fuzzing of MIME Parsers", Andarzian,
    Meyers & Poll, 2025) demonstrates payloads smuggled past filters on
    exactly these constructs, so a consumer running a scanning or
    quarantine policy needs to see them rather than receive a confident
    parse of one of the possible readings.

    They are surfaced as ``_ext.defects`` entries alongside the stdlib
    ones — a signal, not a verdict.
    """
    try:
        content_types = part.get_all("content-type") or []
        encodings = part.get_all("content-transfer-encoding") or []
    except Exception:  # pylint: disable=broad-exception-caught
        # A sufficiently damaged header block can trip the stdlib's own
        # accessors; the absence of a defect flag must never be the thing
        # that aborts a parse.
        return

    # Duplicate structural headers: we take the first, and so does the
    # stdlib. Clients have been observed honouring the *second*, which
    # changes the media type — and with it whether the body is one blob
    # of text or a multipart tree whose attachments we never extract.
    if len(content_types) > 1:
        defects.append("DuplicateContentTypeDefect")
    if len(encodings) > 1:
        defects.append("DuplicateTransferEncodingDefect")

    # An encoding token outside RFC 2045 §6.1. We leave the body
    # undecoded; lenient clients guess (base64 for "bas64", or
    # quoted-printable whenever the data contains "="), which reveals
    # content a scanner reading our output never sees. This also catches
    # the mangled-header case ``Content-Transfer-Encoding:: base64``,
    # where the value arrives as ``": base64"``.
    if encodings:
        token = str(encodings[0]).strip().lower()
        if token and token not in _KNOWN_TRANSFER_ENCODINGS:
            defects.append("UnrecognizedTransferEncodingDefect")

    # RFC 2046 §5.1 says the preamble and epilogue are to be ignored, and
    # we ignore both. Mail clients have been observed rendering preamble
    # text as the message's first line, so it reaches the reader and
    # nothing downstream of this parser; the epilogue is the same gap at
    # the other end of the body.
    preamble = getattr(part, "preamble", None)
    if isinstance(preamble, str) and preamble.strip():
        defects.append("NonEmptyPreambleDefect")
    epilogue = getattr(part, "epilogue", None)
    if isinstance(epilogue, str) and epilogue.strip():
        defects.append("NonEmptyEpilogueDefect")

    # Two boundaries declared on one part: whichever we honour, the other
    # delimits parts we never see. (Inbox Invasion, CCS '24.)
    try:
        ct_params = part.get_params() or []
        cd_params = part.get_params(header="content-disposition") or []
    except Exception:  # pylint: disable=broad-exception-caught
        # A damaged header block can trip the stdlib accessors. Carry on
        # with no parameters rather than returning: the checks below read
        # other sources, and abandoning them would mean the most damaged
        # messages — the ones most worth flagging — get the fewest markers.
        ct_params, cd_params = [], []
    if sum(1 for k, _ in ct_params if k == "boundary") > 1:
        defects.append("DuplicateBoundaryParameterDefect")

    # A multipart whose boundary is absent or empty has no agreed way to
    # split: some parsers give up and treat the body as one flat part,
    # others resynchronise on the next ``--`` line they find.
    try:
        is_multipart = part.get_content_maintype() == "multipart"
    except Exception:  # pylint: disable=broad-exception-caught
        is_multipart = False
    if is_multipart:
        try:
            boundary = _flatten_param(part.get_param("boundary"))
        except Exception:  # pylint: disable=broad-exception-caught
            # Unreadable is not better than absent: either way there is no
            # boundary anyone can agree on.
            boundary = None
        if not boundary:
            defects.append("EmptyBoundaryDefect")

    # RFC 2047 encoded-words are not permitted in MIME parameters — RFC
    # 2231 is the mechanism for non-ASCII there. Senders use them anyway,
    # and receivers split: some decode, some show the raw ``=?…?=``. We
    # decode, so an attachment can carry one name past a scanner that
    # doesn't and a different one to the user.
    for _key, value in list(ct_params) + list(cd_params):
        flat = _flatten_param(value) or ""
        if "=?" in flat and "?=" in flat:
            defects.append("EncodedWordInParameterDefect")
            break

    # NUL and other control characters in a MIME-relevant header value.
    # Parsers disagree on all three available readings — strip them (as we
    # do), truncate at the first one, or keep them — so the same
    # ``report.pdf\x00.exe`` is three different filenames depending on who
    # is looking.
    for _name, raw_value in _iter_mime_header_values(part):
        if any(ord(c) < 0x20 and c not in "\t\r\n" or c == "\x7f" for c in raw_value):
            defects.append("ControlCharInHeaderDefect")
            break

    # A fold inside a quoted parameter value. Legal, and read three
    # different ways — see ``_has_fold_inside_quotes``.
    for _name, raw_value in _iter_mime_header_values(part):
        if _has_fold_inside_quotes(raw_value):
            defects.append("FoldInQuotedParameterDefect")
            break

    # A part we hand over without looking inside.
    try:
        opaque = _OPAQUE_MESSAGE_DEFECTS.get(part.get_content_type())
    except Exception:  # pylint: disable=broad-exception-caught
        opaque = None
    if opaque:
        defects.append(opaque)

    # The part names itself twice and differently. A recipient picking
    # ``Content-Type: name=`` — or honouring RFC 6266 §4.3's preference
    # for ``filename*`` — saves the file under a name the other never
    # saw, which is the whole point of a double extension.
    names = {
        flat
        for flat in (_flatten_param(v) for k, v in cd_params if k == "filename")
        if flat
    }
    ct_name = _flatten_param(dict(ct_params).get("name"))
    if ct_name:
        names.add(ct_name)
    if len(names) > 1:
        defects.append("ConflictingAttachmentNameDefect")


def _is_inline_media_type(content_type: str) -> bool:
    """
    Check if the content type is an inline media type (image/*, audio/*, video/*).

    Args:
        content_type: MIME type string (e.g., "image/png", "audio/mp3")

    Returns:
        True if the type is an inline media type
    """
    return (
        content_type.startswith("image/")
        or content_type.startswith("audio/")
        or content_type.startswith("video/")
    )


def _decoded_part_body(part: Message) -> Any | None:
    """Return the decoded body bytes of ``part`` (or ``None`` if unreadable).

    Real-world inbound carries Content-Transfer-Encoding headers that lie
    about the payload (quoted-printable bodies with raw 8-bit, base64 with
    stray whitespace, etc.). Stdlib's ``get_payload(decode=True)`` raises
    on some of those — we recover the raw payload bytes instead of giving
    up on the whole message.
    """
    try:
        body = part.get_payload(decode=True)
    except (ValueError, AssertionError, TypeError):
        body = None
    if body is not None:
        return body

    # Fall back to whatever the parser actually retained.
    try:
        raw = part.get_payload()
    except (ValueError, TypeError):
        return None
    if isinstance(raw, list):
        # ``message/*`` parts wrap one or more sub-Messages under
        # compat32 — we treat them as opaque attachments and serialize
        # the wrapped content back to bytes for storage.
        # ``message/rfc822`` typically has a single child; but
        # ``message/delivery-status`` (RFC 3464) carries multiple
        # per-recipient status blocks as stacked sub-Messages, so we
        # must concatenate them all to avoid silently dropping later
        # recipients' status. Real multipart containers also surface as
        # lists here but callers gate on maintype before touching the
        # body.
        if raw and part.get_content_maintype() == "message":
            try:
                chunks = [sub.as_bytes() for sub in raw if isinstance(sub, Message)]
                return b"\r\n".join(chunks) if chunks else None
            except (
                TypeError,
                ValueError,
                AttributeError,
                MessageError,
                RecursionError,
            ):
                # ``MessageError`` covers ``HeaderWriteError`` raised
                # by ``BytesGenerator.verify_generated_headers`` when
                # the wrapped sub-message contains a header value with
                # an unfolded newline. ``RecursionError`` covers
                # deeply-nested forward-of-forward chains that blow
                # CPython's recursion limit during MIME re-emission.
                return None
        return None
    if isinstance(raw, str):
        # Under compat32, ``BytesFeedParser`` stores raw 8-bit body
        # bytes as surrogate-escaped code points (U+DC80..U+DCFF). UTF-8
        # encoding with the ``surrogateescape`` error handler recovers
        # the original bytes losslessly — clean unicode text also
        # round-trips correctly through it.
        try:
            return raw.encode("utf-8", errors="surrogateescape")
        except (UnicodeEncodeError, AttributeError):
            return raw.encode("utf-8", errors="replace")
    return raw


def _get_part_info(part: Message) -> dict[str, Any]:
    """
    Extract relevant information from a MIME part for classification.

    Args:
        part: A stdlib ``email.message.Message`` part

    Returns:
        Dictionary with type, disposition, name, body, content_id, part_id
    """
    part_type = part.get_content_type() or "text/plain"

    # ``get_content_disposition`` returns the lowercased main token
    # ("attachment" / "inline" / None) per RFC 6266 — exactly what the
    # classifier downstream wants.
    disposition = part.get_content_disposition()

    # Filename: stdlib's ``get_filename`` handles RFC 2231 continuation
    # and RFC 2047 encoded-word filenames. Anything left over still goes
    # through ``decode_rfc2047_header`` for the encoded-word case
    # where the value sits on a Content-Type ``name=`` parameter.
    filename: str | None = None
    raw_filename = _preferred_filename(part)
    if raw_filename:
        filename = decode_rfc2047_header(str(raw_filename).strip())

    if not filename:
        # Some senders put the filename on Content-Type ``name=`` only.
        name_param = part.get_param("name")
        if name_param:
            # ``get_param`` can return a (charset, lang, value) tuple
            # when the param is RFC 2231-encoded; unwrap.
            if isinstance(name_param, tuple):
                name_param = name_param[2]
            filename = decode_rfc2047_header(str(name_param).strip())

    # Content-ID per RFC 8621 §4.1.4: "CFWS and surrounding angle
    # brackets are removed". ``.strip("<>")`` alone would (a) strip
    # ALL leading/trailing < / > rather than a single outer pair and
    # (b) ignore inline ``(comment)`` blocks. Use the shared CFWS
    # helper, then peel a single outer angle-bracket pair.
    content_id_header = part.get("Content-ID")
    content_id: str | None = None
    if content_id_header:
        cleaned = _strip_cfws(str(content_id_header))
        if cleaned.startswith("<") and cleaned.endswith(">") and len(cleaned) >= 2:
            cleaned = cleaned[1:-1].strip()
        content_id = cleaned or None

    # Body bytes (or None for real multipart containers / unrecoverable).
    # ``is_multipart()`` returns True for message/* too, but we want the
    # body for those (so they can be surfaced as attachments).
    is_container = part.get_content_maintype() == "multipart"
    body = None if is_container else _decoded_part_body(part)

    # Declared charset for text/* parts. Guarded — ``get_content_charset``
    # parses RFC 2231 params and can raise on malformed input under
    # compat32. None means "no charset declared" (RFC 2045 §5.2 defaults
    # to us-ascii, but we treat None as "fall back to UTF-8 with replace"
    # which round-trips ASCII cleanly and is a safer default for the
    # garden-variety untagged-UTF-8 senders we see in practice).
    try:
        charset = part.get_content_charset()
    except (LookupError, ValueError, UnicodeDecodeError, AttributeError):
        charset = None

    # Per-part Content-Language (RFC 3282) — comma-separated tag list.
    # Per RFC 8621 §4.1.4 the JMAP shape is ``String[] | null``.
    language_header = part.get("Content-Language")
    language: list[str] | None = None
    if language_header:
        language = [
            tag.strip() for tag in str(language_header).split(",") if tag.strip()
        ] or None

    # Per-part Content-Location (RFC 2557). String | null per spec.
    location_header = part.get("Content-Location")
    location = str(location_header).strip() if location_header else None
    if not location:
        location = None

    # Per-part header list — JMAP EmailHeader[] in document order with
    # Raw form values (NOT RFC 2047-decoded), mirroring the top-level
    # ``headers`` projection in §4.1.2.
    part_headers: list[dict[str, str]] = []
    try:
        for k, v in part.raw_items():
            # Mirror the top-level headers pipeline: repair stdlib's
            # surrogate-escape mangling of 8-bit bytes, then strip NULs
            # (PostgreSQL TEXT can't carry \x00; downstream stores fail
            # asymmetrically if part headers do and message headers
            # don't).
            part_headers.append(
                {
                    "name": k,
                    "value": _strip_nul_bytes(_repair_surrogate_escaped(str(v))),
                }
            )
    except AttributeError:
        # Defensive: synthesized parts may not expose raw_items().
        for k, v in part.items():
            part_headers.append({"name": k, "value": _strip_nul_bytes(str(v))})

    return {
        "type": part_type,
        "disposition": disposition,
        "name": filename,
        "body": body,
        "charset": charset,
        "content_id": content_id,
        "language": language,
        "location": location,
        "part_headers": part_headers,
        "part_id": "",
    }


def _build_body_part_dict(part_info: dict[str, Any]) -> tuple[EmailBodyPart, bool]:
    """Build a JMAP ``EmailBodyPart`` for textBody/htmlBody arrays.

    Returns ``(part, encoding_problem)``. ``encoding_problem`` is True
    when the text decode hit a missing-charset / wrong-charset fallback;
    callers route it into ``bodyValues[partId].isEncodingProblem``.

    Per RFC 8621 §4.1.4: ``partId``, ``size``, ``name``, ``type``,
    ``charset``, ``disposition``, ``cid`` are first-class fields. We
    additionally inline ``content`` (the decoded body) for ergonomic
    direct access; the ``body_values=True`` opt-in strips it back out.
    """
    body = part_info["body"]
    part_type = part_info["type"]

    # Binary types (images, audio, video) — base64-encode for JSON transport.
    encoding_problem = False
    if _is_inline_media_type(part_type):
        if body is None:
            content = ""
            size = 0
        elif isinstance(body, bytes):
            content = base64.b64encode(body).decode("ascii")
            size = len(body)
        else:
            content = base64.b64encode(body.encode("latin-1")).decode("ascii")
            size = len(body.encode("latin-1"))
    # Text types — decode using the part's declared charset (RFC 2045)
    # so Windows-1252, ISO-8859-*, GB2312 etc. survive intact. Falls
    # back to UTF-8 with replacement on missing/unknown charsets so
    # untagged 8-bit input still produces a printable string.
    elif body is not None and not isinstance(body, str):
        charset = part_info.get("charset") or "utf-8"
        try:
            content = body.decode(charset, errors="replace")
        except (LookupError, UnicodeDecodeError):
            content = body.decode("utf-8", errors="replace")
            encoding_problem = True
        size = len(body)
    else:
        content = body or ""
        size = len(content.encode("utf-8")) if content else 0

    part = EmailBodyPart(
        partId=part_info["part_id"],
        # ``blobId`` is server-set in real JMAP (identifies a blob in the
        # store). The library has no blob store; we emit ``None`` so the
        # field shape matches the spec and the caller can assign one
        # before sending the object to a JMAP server.
        blobId=None,
        type=part_type,
        size=size,
        name=part_info["name"] or None,
        charset=part_info.get("charset") or None,
        disposition=part_info["disposition"] or None,
        cid=part_info["content_id"],
        language=part_info.get("language"),
        location=part_info.get("location"),
        headers=part_info.get("part_headers") or [],
        # Leaf parts have no children.
        subParts=None,
        content=_strip_nul_bytes(content),
    )
    return part, encoding_problem


def _build_attachment_from_part_info(
    part_info: dict[str, Any], disposition_override: str = "attachment"
) -> EmailBodyPart:
    """Build a JMAP ``EmailBodyPart`` for the attachments array."""
    disposition = part_info["disposition"] or disposition_override
    # JMAP spec: ``name`` is ``String | null``. We don't substitute
    # ``"unnamed"`` here — if a downstream consumer wants a default,
    # they can synthesize one. ``sanitize_filename`` returns ``None``
    # for a missing name and for one that sanitizes away to nothing,
    # which is exactly the field's shape.
    body_bytes = part_info["body"] or b""
    if isinstance(body_bytes, str):
        body_bytes = body_bytes.encode("utf-8")
    return {
        "partId": part_info["part_id"],
        "blobId": None,
        "type": part_info["type"],
        "size": len(body_bytes),
        "name": sanitize_filename(part_info["name"]),
        "charset": part_info.get("charset") or None,
        "disposition": disposition,
        "cid": part_info["content_id"],
        "language": part_info.get("language"),
        "location": part_info.get("location"),
        "headers": part_info.get("part_headers") or [],
        "subParts": None,
        # Project extensions: inline bytes + sha256 so callers without a
        # blob store can still index / re-emit the part. See the
        # ``EmailBodyPart`` docstring in :mod:`jmap_email.types`.
        "content": body_bytes,
        "sha256": hashlib.sha256(body_bytes).hexdigest(),
    }


def _build_body_structure(
    message: Message, options: ParseOptions
) -> EmailBodyPart | None:
    """Recursively build a JMAP ``bodyStructure`` tree.

    Per RFC 8621 §4.1.4, ``bodyStructure`` is the entire ``EmailBodyPart``
    tree rooted at the top-level part. Each multipart container exposes
    its children as ``subParts``; leaf parts carry the same fields as
    ``textBody`` / ``attachments`` entries (without ``content`` here —
    content lives in ``bodyValues`` when requested).

    Returns ``None`` on walk error.
    """
    try:
        return _build_body_structure_node(
            message, ["1"], counter={"parts": 0}, options=options
        )
    except (RecursionError, ValueError, TypeError, AttributeError):
        return None


def _build_body_structure_node(
    part: Message,
    path: list[str],
    *,
    options: ParseOptions,
    depth: int = 0,
    counter: dict[str, int] | None = None,
) -> EmailBodyPart:
    """Build a single node of the bodyStructure tree.

    Enforces the same depth + part-count caps as the default body walk
    (``_parse_body_structure``). A flat multipart with a million sub-
    parts under one container would otherwise bypass
    ``options.max_mime_parts`` when the caller opted into
    ``body_structure=True``.

    Per RFC 8621 §4.1.4: ``partId`` (and ``blobId``) MUST be ``null``
    if and only if the part is ``multipart/*``.
    """
    if counter is None:
        counter = {"parts": 0, "next_part_id": 0}
    counter.setdefault("next_part_id", 0)
    is_multipart = part.get_content_maintype() == "multipart"

    # Leaf partIds must match the flat counter assigned by
    # ``_parse_body_structure`` so ``bodyStructure["partId"]`` can be
    # used as a lookup key into ``bodyValues`` and against the entries
    # in ``textBody`` / ``htmlBody`` / ``attachments`` (RFC 8621 §4.1.4).
    # The hierarchical ``path`` is kept only for the truncation walk's
    # diagnostic value — it is not exposed.
    if not is_multipart:
        counter["next_part_id"] += 1
        leaf_part_id: str | None = str(counter["next_part_id"])
    else:
        leaf_part_id = None

    if (
        depth > options.max_mime_nesting_depth
        or counter["parts"] >= options.max_mime_parts
    ):
        # Truncated stub: still spec-shaped (null partId/blobId for
        # multipart) so consumers don't have to special-case it.
        return {
            "partId": leaf_part_id,
            "blobId": None,
            "type": part.get_content_type() or "text/plain",
            "size": 0,
            "name": None,
            "charset": None,
            "disposition": None,
            "cid": None,
            "language": None,
            "location": None,
            "headers": [],
            "subParts": [] if is_multipart else None,
        }
    counter["parts"] += 1

    info = _get_part_info(part)
    info["part_id"] = leaf_part_id or ""
    node: EmailBodyPart = EmailBodyPart(
        # multipart/* parts: partId and blobId are null per RFC 8621 §4.1.4
        # "if and only if". Leaf parts get a stable flat id matching
        # the one assigned in ``textBody`` / ``htmlBody`` / ``attachments``.
        partId=leaf_part_id,
        blobId=None,
        type=info["type"],
        size=0 if is_multipart else len(info["body"] or b""),
        name=info["name"] or None,
        charset=info["charset"] or None,
        disposition=info["disposition"] or None,
        cid=info["content_id"],
        language=info.get("language"),
        location=info.get("location"),
        headers=info.get("part_headers") or [],
        subParts=None,
    )
    if is_multipart:
        # Truncate the subParts list at the part-count cap. The deep-cap
        # check at function entry catches one runaway level; this loop-
        # level cap stops a single flat multipart with a million siblings
        # from emitting a million stub nodes (each stub costs memory
        # even if not recursed into).
        children = _subparts(part)
        sub_nodes: list[EmailBodyPart] = []
        for i, child in enumerate(children):
            if counter["parts"] >= options.max_mime_parts:
                logger.warning(
                    "MIME part count exceeds limit %d; truncating bodyStructure",
                    options.max_mime_parts,
                )
                break
            sub_nodes.append(
                _build_body_structure_node(
                    child,
                    path + [str(i + 1)],
                    depth=depth + 1,
                    counter=counter,
                    options=options,
                )
            )
        node["subParts"] = sub_nodes
    return node


def _subparts(part: Message) -> list[Message]:
    """Return the list of immediate sub-parts of ``part`` (empty if none).

    A multipart container's payload is a list of ``Message`` objects;
    a leaf part's payload is a str/bytes. We never want str/bytes to be
    interpreted as a sub-part list, so guard explicitly.
    """
    if not part.is_multipart():
        return []
    payload = part.get_payload()
    if not isinstance(payload, list):
        return []
    return [p for p in payload if isinstance(p, Message)]


def _parse_body_structure(
    parts: list[Message],
    multipart_type: str,
    in_alternative: bool,
    html_body: list[EmailBodyPart] | None,
    text_body: list[EmailBodyPart] | None,
    attachments: list[EmailBodyPart],
    *,
    options: ParseOptions,
    depth: int = 0,
    counter: dict[str, Any] | None = None,
    parent_boundaries: tuple[str, ...] = (),
) -> None:
    """
    Recursively parse MIME structure following JMAP spec algorithm (Section 4.1).

    This implements the parseStructure algorithm from the JMAP specification,
    with a modification: inline media types are NOT added to attachments when
    one of textBody/htmlBody is null (unlike the spec example).

    Args:
        parts: List of MIME parts to process
        multipart_type: Type of parent multipart (mixed/alternative/related)
        in_alternative: Whether we're inside a multipart/alternative
        html_body: List to append HTML body parts (or None if nullified)
        text_body: List to append text body parts (or None if nullified)
        attachments: List to append attachment parts
        depth: Current recursion depth (guards against MIME bombs that
            would otherwise blow Python's recursion limit).
        counter: Shared mutable counter dict tracking total visited
            parts across the whole walk. Caps prevent
            ``multipartmaxparts``-style DoS at low depth (e.g. 1000
            flat parts that wouldn't trip the depth guard).
        parent_boundaries: MIME boundaries of every multipart ancestor on
            the current path. If a child multipart re-declares one of
            these boundaries we refuse to recurse — the inner delimiters
            collide with an ancestor's, so the inner tree is ambiguous
            and parsers will disagree on what content it holds (the
            Mailsploit / body-smuggling class). Dropping the inner tree
            is safer than surfacing parts that other receivers might
            interpret differently.
    """
    if counter is None:
        counter = {"parts": 0, "next_part_id": 0}
    counter.setdefault("next_part_id", 0)

    # Hard depth cap to defeat MIME-bomb style inputs (deeply nested
    # multiparts crafted to exhaust CPython's recursion limit). Below
    # the cap, real-world legitimate messages are unaffected.
    if depth > options.max_mime_nesting_depth:
        logger.warning(
            "MIME nesting depth %d exceeds limit %d; truncating walk",
            depth,
            options.max_mime_nesting_depth,
        )
        return

    # Track lengths for multipart/alternative fallback
    text_length = len(text_body) if text_body is not None else -1
    html_length = len(html_body) if html_body is not None else -1

    for i, part in enumerate(parts):
        if counter["parts"] >= options.max_mime_parts:
            logger.warning(
                "MIME part count exceeds limit %d; truncating walk",
                options.max_mime_parts,
            )
            return
        counter["parts"] += 1
        part_type = (part.get_content_type() or "text/plain").lower()
        # ``part.is_multipart()`` is True for both ``multipart/*`` *and*
        # ``message/*`` (since stdlib parses message/rfc822 recursively
        # into a sub-Message). For the JMAP structure walk we only want
        # to recurse into real multipart containers — a message/rfc822
        # sub-message must be surfaced as a single attachment, not
        # exploded into its inner parts.
        is_multipart = part.get_content_maintype() == "multipart"

        # Get part info for classification, assigning a stable partId.
        part_info = _get_part_info(part)
        # Multipart containers don't get a partId in our flattened
        # output (they have no body content). Leaf parts get a stable
        # incrementing ID.
        if not is_multipart:
            counter["next_part_id"] += 1
            part_info["part_id"] = str(counter["next_part_id"])

        # Determine if this is an inline body part (not attachment)
        # Per JMAP spec: disposition != "attachment" AND
        # (type is text/plain OR text/html OR inline media) AND
        # (first part OR (not in related AND (is inline media OR no filename)))
        is_inline = (
            part_info["disposition"] != "attachment"
            and (
                part_type in {"text/plain", "text/html"}
                or _is_inline_media_type(part_type)
            )
            and (
                i == 0
                or (
                    multipart_type != "related"
                    and (_is_inline_media_type(part_type) or not part_info["name"])
                )
            )
        )

        if is_multipart:
            # Boundary-reuse defence (RFC 2046 §5.1.1: a boundary value
            # must not appear inside an encapsulated part). When an
            # inner multipart re-declares a boundary that an ancestor
            # already uses, the inner delimiters are ambiguous — strict
            # and lenient parsers will split the tree differently, so
            # an attacker can hide content that some receivers surface
            # and others don't. Refuse the recursion and drop the
            # opaque subtree.
            sub_boundary = part.get_boundary()
            if sub_boundary and sub_boundary in parent_boundaries:
                logger.warning(
                    "MIME boundary %r reused at depth %d; "
                    "marking structure ambiguous (body-smuggling defence)",
                    sub_boundary,
                    depth + 1,
                )
                # Once any ancestor's boundary is re-declared, stdlib's
                # split of the rest of the tree is no longer well-defined
                # — sibling text/plain parts at the outer level may
                # actually belong to the inner subtree depending on
                # which parser you ask. We flag the whole walk so the
                # top-level caller clears all collected bodies; safer
                # than letting some receivers process content others
                # would hide.
                counter["ambiguous_structure"] = 1
                continue

            # Recurse into multipart
            sub_multipart_type = part.get_content_subtype() or "mixed"
            sub_parts = _subparts(part)
            sub_parents = (
                (*parent_boundaries, sub_boundary)
                if sub_boundary
                else parent_boundaries
            )
            _parse_body_structure(
                sub_parts,
                sub_multipart_type,
                in_alternative or sub_multipart_type == "alternative",
                html_body,
                text_body,
                attachments,
                depth=depth + 1,
                counter=counter,
                options=options,
                parent_boundaries=sub_parents,
            )

        elif is_inline:
            # Handle inline parts based on context
            encoding_problems = counter.setdefault("encoding_problems", {})
            if multipart_type == "alternative":
                # In direct alternative: route based on type only
                if part_type == "text/plain":
                    if text_body is not None:
                        body_part, ep = _build_body_part_dict(part_info)
                        text_body.append(body_part)
                        if ep:
                            encoding_problems[part_info["part_id"]] = True
                elif part_type == "text/html":
                    if html_body is not None:
                        body_part, ep = _build_body_part_dict(part_info)
                        html_body.append(body_part)
                        if ep:
                            encoding_problems[part_info["part_id"]] = True
                else:
                    # Other types in alternative go to attachments
                    attachments.append(_build_attachment_from_part_info(part_info))
                continue

            # Outside alternative but within an alternative ancestor
            if in_alternative:
                # text/plain nullifies htmlBody locally
                if part_type == "text/plain":
                    html_body = None
                # text/html nullifies textBody locally
                if part_type == "text/html":
                    text_body = None

            # Push to both arrays if not nullified
            if text_body is not None or html_body is not None:
                body_part, ep = _build_body_part_dict(part_info)
                if ep:
                    encoding_problems[part_info["part_id"]] = True
                if text_body is not None:
                    text_body.append(body_part)
                if html_body is not None:
                    html_body.append(body_part)

            # NOTE: We intentionally skip the JMAP spec's condition:
            # if ((!textBody || !htmlBody) && isInlineMediaType) attachments.push(part)
            # This is our modification to not duplicate inline media in attachments

        else:
            # Non-inline parts go to attachments
            attachments.append(_build_attachment_from_part_info(part_info))

    # Handle multipart/alternative fallback:
    # If only one type was found, copy to the other array
    if (
        multipart_type == "alternative"
        and text_body is not None
        and html_body is not None
    ):
        # Found HTML part only - copy to textBody
        if text_length == len(text_body) and html_length != len(html_body):
            for j in range(html_length, len(html_body)):
                text_body.append(html_body[j])
        # Found text part only - copy to htmlBody
        if html_length == len(html_body) and text_length != len(text_body):
            for j in range(text_length, len(text_body)):
                html_body.append(text_body[j])


def _parse_message_content(
    message,
    *,
    options: ParseOptions = DEFAULT_PARSE_OPTIONS,
    defects: list[str] | None = None,
) -> dict[str, Any]:
    """Extract textBody / htmlBody / attachments from a message, JMAP-shaped.

    Uses the JMAP spec's parseStructure algorithm (RFC 8621 §4.1.4) to
    handle multipart structures (alternative / related / mixed). Inline
    media types (image/* etc.) stay in textBody/htmlBody rather than
    being demoted to attachments. ``Content-Disposition: attachment``
    forces the attachments array.

    Failures in the recursive walk are recorded in ``defects`` (when
    provided) so consumers see a programmatic signal beyond a log line.
    """
    result: dict[str, Any] = {"textBody": [], "htmlBody": [], "attachments": []}

    # Some malformed inputs end up as a ``Message`` with no Content-Type
    # at all and a raw str body. Stdlib defaults Content-Type to
    # ``text/plain`` so most paths just work, but we still keep the
    # explicit fallback for non-``Message`` inputs (tests poke us with
    # mocks).
    if not isinstance(message, Message):
        body = getattr(message, "body", None)
        if isinstance(body, str):
            result["textBody"].append(
                {
                    "partId": "1",
                    "blobId": None,
                    "type": "text/plain",
                    "size": len(body.encode("utf-8")),
                    "name": None,
                    "charset": None,
                    "disposition": None,
                    "cid": None,
                    "language": None,
                    "location": None,
                    "headers": [],
                    "subParts": None,
                    "content": _strip_nul_bytes(body),
                }
            )
        return result

    counter: dict[str, Any] = {"parts": 0, "next_part_id": 0}
    try:
        # Use the JMAP-style recursive parser
        # Wrap the message in a list and treat it as if inside multipart/mixed
        _parse_body_structure(
            [message],
            "mixed",
            False,
            result["htmlBody"],
            result["textBody"],
            result["attachments"],
            options=options,
            counter=counter,
        )
    except Exception as e:  # pylint: disable=broad-exception-caught
        logger.error("Error parsing message body structure: %s", e, exc_info=True)
        if defects is not None:
            defects.append("BodyStructureWalkError")

    # Boundary-reuse defence (Mailsploit class): once any inner multipart
    # re-declares an ancestor's boundary, stdlib's split of the rest of
    # the tree is ambiguous — sibling parts at outer levels may actually
    # belong to the hidden inner subtree. We clear everything so strict
    # and lenient receivers agree on what the message carries (nothing).
    if counter.get("ambiguous_structure"):
        if defects is not None:
            defects.append("BoundaryReuseAmbiguousStructure")
        return {
            "textBody": [],
            "htmlBody": [],
            "attachments": [],
            "encoding_problems": {},
        }

    result["encoding_problems"] = counter.get("encoding_problems") or {}
    return result


# ────────────────────────────────────────────────────────────────────
# JMAP shape helpers (RFC 8621 §4.1.2 — typed header projections)
# ────────────────────────────────────────────────────────────────────


def _jmap_addresses(pairs: list[tuple[str, str]]) -> list[EmailAddress] | None:
    """Convert a list of ``(name, addr)`` tuples to JMAP ``EmailAddress[]``.

    Per RFC 8621 §4.1.2.3, ``EmailAddress = {name: String|null, email: String}``.
    Returns ``None`` when ``pairs`` is empty so callers can distinguish
    "no header" from "header present but empty".
    """
    if not pairs:
        return None
    return [{"name": name or None, "email": addr} for name, addr in pairs]


def _jmap_message_ids(raw_header_value: str) -> list[str] | None:
    """Split a Message-ID / In-Reply-To / References header into ``String[]``.

    Per RFC 8621 §4.1.2.1, ``MessageIds = String[]`` where each string is
    the msg-id with surrounding ``<>`` AND surrounding CFWS removed.
    Comments may appear between ids (``<id1@x> (forwarded) <id2@x>``),
    so we strip CFWS via :func:`_strip_cfws` before splitting on
    whitespace.

    Returns ``None`` when the header value is empty / absent.
    """
    if not raw_header_value:
        return None
    cleaned = _strip_cfws(raw_header_value)
    ids: list[str] = []
    for token in cleaned.split():
        stripped = token.strip()
        if stripped.startswith("<") and stripped.endswith(">") and len(stripped) >= 2:
            stripped = stripped[1:-1]
        if stripped:
            ids.append(stripped)
    return ids or None


def _jmap_iso_date(dt: datetime | None) -> str | None:
    """Format a datetime as RFC 8621 ``Date`` (ISO-8601 with offset).

    JMAP's ``Date`` keeps the original UTC offset (unlike ``UTCDate``
    which always normalizes to ``Z``). A naive datetime is treated as
    UTC. Returns ``None`` when ``dt`` is ``None`` so a missing Date
    header surfaces as ``null``.
    """
    if dt is None:
        return None
    if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
        dt = dt.replace(tzinfo=dt_timezone.utc)
    # ``datetime.isoformat`` emits ``+HH:MM`` for fixed offsets and
    # ``+00:00`` for UTC — matching JMAP's ``Date`` exactly.
    return dt.isoformat()


def _nfc(text: str) -> str:
    """NFC-normalize text per RFC 8621 §4.1.2.4 ``Text`` form."""
    return unicodedata.normalize("NFC", text)


def _jmap_subject(raw_value: str | None) -> str | None:
    """Return the JMAP ``subject`` field: NFC-normalized text, ``None``
    when the Subject header was absent (preserves the spec's null-vs-
    empty distinction)."""
    if raw_value is None:
        return None
    return _nfc(_strip_nul_bytes(raw_value))


def _jmap_headers(raw_headers: list[tuple[str, str, str]]) -> list[dict[str, str]]:
    """Build the JMAP ``headers`` field: ``EmailHeader[]`` in document order.

    Per RFC 8621 §4.1.1 + §4.1.2 Raw form: ``EmailHeader = {name: String,
    value: String}`` where ``name`` preserves the wire case and ``value``
    is the **raw** header octets — surrogate-repaired, but NOT RFC 2047-
    decoded, NOT internal-whitespace-collapsed beyond the unfolding the
    spec mandates (CRLF+WSP → SP). Consumers needing decoded text
    should use the typed projections (``subject``, ``from``, etc.) or
    pass each ``value`` through :func:`decode_rfc2047_header` themselves.

    ``raw_headers`` is a list of ``(wire_name, decoded_value, raw_value)``
    tuples; we emit the raw_value slot for spec conformance and
    DKIM-canonicalisation / forensic-replay use cases.
    """
    return [
        {"name": wire_name, "value": raw_value}
        for wire_name, _, raw_value in raw_headers
    ]


def _compute_has_attachment(attachments: list[EmailBodyPart]) -> bool:
    """Compute the JMAP ``hasAttachment`` field.

    Per RFC 8621 §4.1.4: "A server SHOULD set ``hasAttachment`` to
    true if the attachments list contains at least one item that does
    not have ``Content-Disposition: inline``." The check is therefore
    "any disposition that isn't literally ``inline``" — covers items
    with ``attachment`` *and* any non-standard disposition value
    (``form-data``, etc.) that a real-world sender might emit.
    """
    return any(att.get("disposition") != "inline" for att in attachments)


def _inline_text_content(part: EmailBodyPart) -> str:
    """Return a part's inline ``content`` narrowed to ``str``.

    ``EmailBodyPart.content`` is typed ``str | bytes`` because some
    parts (e.g. inline images riding in ``textBody`` / ``htmlBody`` for
    rendering) may carry raw bytes, while ``text/*`` parts always carry
    ``str``. That conditional invariant cannot be expressed in the
    TypedDict, so this accessor narrows the union at a single boundary:
    ``bytes`` (or absence) yields ``""`` rather than leaking a
    ``b'...'`` repr downstream.

    Note: inline media in ``textBody`` / ``htmlBody`` carries base64
    ``str`` content, so this narrowing does not exclude it — callers
    that want prose only must additionally filter on the part ``type``
    (see :func:`_first_textual_content`).
    """
    content = part.get("content")
    return content if isinstance(content, str) else ""


def _first_textual_content(parts: list[EmailBodyPart]) -> tuple[str, str]:
    """Return ``(content, type)`` of the first ``text/*`` part, skipping
    inline media.

    ``textBody`` / ``htmlBody`` interleave inline media (e.g. a leading
    logo) whose ``content`` is a base64 ``str``, not prose. Feeding that
    to the preview would emit unreadable base64 and pay the cleaning cost
    over a large blob, so inline-media parts are skipped in favour of the
    first part that actually carries text.

    The part's own ``type`` is returned alongside its content because the
    preview cleaner keys off it: the ``htmlBody`` list is not *guaranteed*
    to hold ``text/html`` (a sender can put a ``text/plain`` alternative
    there), so inferring the type from which list we drew is a guess where
    the part itself carries the answer.
    """
    for part in parts:
        part_type = part.get("type", "")
        if not _is_inline_media_type(part_type):
            content = _inline_text_content(part)
            if content:
                return content, part_type
    return "", ""


def _compute_preview(
    text_body: list[EmailBodyPart],
    html_body: list[EmailBodyPart],
    max_chars: int,
    max_scan_bytes: int,
) -> str:
    """Compute the JMAP ``preview`` field: a single-line excerpt drawn
    from the HTML body, falling back to the plain-text body, truncated to
    ``max_chars``.

    HTML is preferred because that is the part mail clients render, so
    the preview reflects what the user will actually see. Deriving it
    from ``text/plain`` instead would let a ``multipart/alternative``
    whose text and HTML parts disagree (a known preview-spoofing trick)
    show a preview that mismatches the rendered body.

    The fallback triggers on an empty *preview*, not just an absent HTML
    part: a link-/image-only HTML body yields no visible text, and the
    text/plain alternative usually carries the real message — so we use it
    rather than ship a blank preview.

    Per RFC 8621 §4.1.4, ``preview`` is server-set, single-line, and
    ``MUST NOT`` exceed 256 characters; ``max_chars`` (sourced from
    :attr:`ParseOptions.max_preview_chars`, default 256) is that cap.
    The heavy lifting (HTML + markdown strip, then truncate) is done
    by :func:`jmap_email.preview_text` — cleaning happens before
    truncation so leading syntax never consumes the preview budget.

    Each source is cleaned according to its own part ``type``: the
    text/plain conventions (``>``-quoted lines, markdown syntax) would
    delete literal content from an HTML part, so ``preview_text`` is told
    which wire format it was handed.
    """
    html_src, html_type = _first_textual_content(html_body)
    preview = (
        preview_text(
            html_src,
            max_chars=max_chars,
            max_scan_bytes=max_scan_bytes,
            content_type=html_type,
        )
        if html_src
        else ""
    )
    if not preview:
        text_src, text_type = _first_textual_content(text_body)
        if text_src:
            preview = preview_text(
                text_src,
                max_chars=max_chars,
                max_scan_bytes=max_scan_bytes,
                content_type=text_type,
            )
    return preview


def _build_body_values(
    text_body: list[EmailBodyPart],
    html_body: list[EmailBodyPart],
    encoding_problems: dict[str, bool],
) -> dict[str, dict[str, Any]]:
    """Build the JMAP ``bodyValues`` map: ``{partId: EmailBodyValue}``.

    Per RFC 8621 §4.1.4, ``bodyValues`` is a map for ``text/*`` parts
    only — non-text parts (inline images, audio, video) that ride in
    ``textBody`` / ``htmlBody`` for rendering purposes are excluded.

    Per §4.1.4 ``EmailBodyValue.value``: line endings are normalised
    to ``\\n`` (CR / CRLF / LF all collapse to LF).

    ``isTruncated`` is always ``false`` — we never truncate.
    ``isEncodingProblem`` flips when the part's charset was missing or
    unknown and the body decoder fell back to utf-8/replace; the
    producer records that signal in ``encoding_problems`` keyed by
    ``partId``.
    """
    values: dict[str, dict[str, Any]] = {}
    for part in (*text_body, *html_body):
        part_id = part.get("partId")
        if not part_id:
            continue
        # Spec restricts the map to ``text/*`` parts. An inline image
        # in textBody has type=image/png and its ``content`` is base64
        # — emitting it as a string value here would be a category
        # error for clients reading ``bodyValues``.
        if not (part.get("type") or "").lower().startswith("text/"):
            continue
        raw = _inline_text_content(part)
        # Per spec §4.1.4: "CRLF, LF, and CR should be normalized to LF."
        normalised = raw.replace("\r\n", "\n").replace("\r", "\n")
        values.setdefault(
            part_id,
            {
                "value": normalised,
                "isEncodingProblem": encoding_problems.get(part_id, False),
                "isTruncated": False,
            },
        )
    return values


def _strip_body_part_content(
    body_parts: list[EmailBodyPart],
) -> list[EmailBodyPart]:
    """Remove ``content`` from each body part — used when ``body_values``
    is enabled so the content lives in the ``bodyValues`` map only."""
    return [
        cast(EmailBodyPart, {k: v for k, v in p.items() if k != "content"})
        for p in body_parts
    ]


def parse_email(
    raw_email_bytes: bytes,
    *,
    extensions: bool = False,
    body_values: bool = True,
    body_structure: bool = False,
    preview: bool = True,
    options: ParseOptions = DEFAULT_PARSE_OPTIONS,
) -> JmapEmail | None:
    """Parse raw RFC 5322 bytes into a JMAP Email object (RFC 8621 §4).

    Output is a strict-JMAP-conformant dict by default. Server-set
    metadata properties (``id``, ``blobId``, ``threadId``, ``mailboxIds``,
    ``keywords``, ``size``, ``receivedAt``) are intentionally absent —
    they belong to the message store.

    The defaults follow RFC 8621 §4.2 ``defaultProperties`` for
    ``Email/get``: ``preview`` and ``bodyValues`` are emitted by
    default so a plain ``parse_email(raw)`` produces a
    spec-default-conformant Email object. Set them to ``False`` when
    parse-time cost matters on a hot path that doesn't need them.

    Parameters
    ----------
    raw_email_bytes : bytes
        The raw RFC 5322 message.
    extensions : bool, default False
        Emit the ``_ext`` sub-dict carrying project extensions outside
        the RFC 8621 wire shape: ``ext["defects"]`` (stdlib MIME
        defect class names) and ``ext["resent"]`` (Resent-* typed
        projection, present only when the wire actually carries any
        Resent-* header). Set ``True`` to read them.
    body_values : bool, default True
        Emit a top-level ``bodyValues`` map keyed by ``partId`` per
        RFC 8621 §4.1.5. Body parts then carry only metadata; the
        ``content`` field is removed from text/html parts (attachments
        keep their ``content: bytes`` extension regardless). Set to
        ``False`` to skip the ``bodyValues`` map and inline ``content``
        directly on every part — slightly cheaper for one-shot reads.
    body_structure : bool, default False
        Emit a top-level ``bodyStructure`` field — the recursive tree
        of every part. Adds the MIME-tree walk overhead.
    preview : bool, default True
        Compute ``preview``: a single-line ≤ 256-char plain-text excerpt.
        Set to ``False`` when you don't need it; the cost is one HTML
        strip + a unicode-space normalise per message.
    options : ParseOptions, default :data:`DEFAULT_PARSE_OPTIONS`
        Per-call resource caps (MIME nesting depth, total part count,
        per-header byte cap). See :class:`ParseOptions` for the
        attribute table. Pass a custom instance to widen / tighten the
        defaults independently of any other parse call in the process.

    Returns
    -------
    dict or None
        A JMAP Email object on success (see module docstring for the
        property table). ``None`` when the input cannot be parsed at
        all (empty bytes, wrong type, stdlib producing no
        :class:`email.message.Message`, an unhandled exception during
        the walk). Every failure is logged at WARNING level.

        Recoverable damage (a salvageable malformed header, an unknown
        encoding, etc.) does **not** trigger ``None`` — it surfaces in
        ``result["_ext"]["defects"]`` so callers can flag the message
        while still using its parsed fields.
    """
    return _parse_email(
        raw_email_bytes,
        extensions=extensions,
        body_values=body_values,
        body_structure=body_structure,
        preview=preview,
        options=options,
    )


def _parse_email(
    raw_email_bytes: bytes,
    *,
    extensions: bool,
    body_values: bool,
    body_structure: bool,
    preview: bool,
    options: ParseOptions,
) -> JmapEmail | None:
    """Implementation of ``parse_email``. Kept separate so the public
    function carries a clean docstring and signature while this carries
    the per-header view construction.

    Three header views are computed in lockstep during the parse walk:
    - ``decoded_by_name`` — ``dict[str, list[str]]`` keyed by lowercase
      header name. Every occurrence of every header in document order;
      scalar JMAP fields read ``[0]`` (first wins on duplication,
      matching stdlib ``email.message.Message[name]``).
    - ``wire_headers`` — every header in document order as
      ``(wire_case_name, decoded_value, raw_value)``; surfaced as
      ``parsed["headers"]`` (a JMAP ``EmailHeader[]``).
    - ``headers_blocks`` — ``list[dict[str, list[str]]]``. Each block
      ends with a ``Received`` header; everything above (earlier) it
      is in the same trust scope. Values inside a block are *always*
      ``list[str]`` so trusted-relays filters can index uniformly.

    Args:
        raw_email_bytes: Raw email data as bytes.

    Returns:
        Dict of parsed fields, or ``None`` on fundamental parse failure
        (logged at WARNING level). Never raises.
    """
    if not raw_email_bytes or not isinstance(raw_email_bytes, bytes):
        logger.warning(
            "parse_email: empty or non-bytes input (type=%s); returning None",
            type(raw_email_bytes).__name__,
        )
        return None

    try:
        # Stdlib parser under compat32. ``message_from_bytes`` never
        # raises on malformed input under this policy — recoverable
        # damage is recorded in ``message.defects`` and we walk the
        # structure best-effort.
        message = _message_from_bytes(raw_email_bytes)

        if message is None:
            logger.warning(
                "parse_email: stdlib produced no Message (input length %d); returning None",
                len(raw_email_bytes),
            )
            return None

        # Extract all headers, normalizing keys to lowercase. We use
        # ``raw_items()`` rather than ``items()`` to get the *raw*
        # string the parser stored (under compat32, BytesFeedParser
        # escapes non-ASCII bytes via ``surrogateescape``). The public
        # items() view wraps each value in a ``Header`` object whose
        # str() collapses lone surrogates to U+FFFD, irreversibly
        # destroying non-ASCII content (e.g. UTF-8 X-Gmail-Labels).
        # See ``_repair_surrogate_escaped`` for the recovery path.
        # Lowercase-keyed map of every occurrence of every header, in
        # document order. Scalar JMAP fields take ``[0]`` (first wins,
        # matching stdlib ``email.message.Message[name]`` semantics).
        decoded_by_name: dict[str, list[str]] = defaultdict(list)
        # First-occurrence raw value per header name (surrogate-repaired
        # but NOT RFC 2047 decoded). Used by ``parse_address`` /
        # ``parse_addresses`` to defend against the PortSwigger
        # "Splitting the Email Atom" smuggling technique: pre-decoding
        # the whole header would let encoded-words inside the addr-spec
        # surface ``@``/``<``/``,`` as structural splitters.
        raw_addr_headers: dict[str, str] = {}
        # Document-order header source-of-truth: each tuple is
        # ``(wire_case_name, decoded_value, raw_value)``. ``decoded_value``
        # has RFC 2047 + surrogate repair applied; ``raw_value`` is the
        # surrogate-repaired but otherwise-untouched header text.
        wire_headers: list[tuple[str, str, str]] = []
        # Lowercase-key view in document order, used to build
        # ``headers_blocks`` (Received-bounded trust scopes).
        lower_headers: list[tuple[str, str]] = []

        for k, v in message.raw_items():
            raw_value = _repair_surrogate_escaped(str(v))
            # Cap on individual header values, matching Postfix's
            # ``header_size_limit``. RFC 5322 §2.2.3 puts no limit on a
            # field ("may be indeterminately long" — folding makes it
            # unbounded while every line stays legal), so this is local
            # policy, and above it the quadratic hot spots in
            # ``_header_value_parser`` (gh-136063) start to hurt.
            #
            # Rejected, not truncated. Postfix discards the excess, but
            # it is rewriting a header, not asserting identity from one:
            # a byte cut lands mid-token and can *manufacture* a value
            # that was never sent. There is no generally safe cut point
            # for an arbitrary field — a shortened ``Received`` still
            # looks well-formed to the trust-scope logic — so a message
            # carrying one is refused rather than silently reshaped.
            #
            # Measured in octets, not characters: the value has already
            # been decoded from the wire, so a non-ASCII field is up to
            # 4x longer in bytes than in ``len()`` and a character count
            # would let it past a cap named for bytes.
            if (
                len(raw_value.encode("utf-8", "surrogateescape"))
                > options.max_header_value_bytes
            ):
                logger.warning(
                    "parse_email: header %s exceeds %d bytes; returning None",
                    k,
                    options.max_header_value_bytes,
                )
                return None
            # NUL bytes in headers would either reach a downstream text
            # store (PostgreSQL rejects \x00 in TEXT) or smuggle past a
            # naive C-string parser. Strip before any decode + before
            # the value lands in wire_headers.
            raw_value = _strip_nul_bytes(raw_value)
            decoded_value = decode_rfc2047_header(raw_value)
            key_lower = k.lower()
            wire_headers.append((k, decoded_value, raw_value))
            lower_headers.append((key_lower, decoded_value))

            decoded_by_name[key_lower].append(decoded_value)
            raw_addr_headers.setdefault(key_lower, raw_value)

        def _first_value(name: str) -> str:
            """Return the first decoded occurrence of ``name`` or ``""``.

            Centralizes the ``email.message.Message[name]`` semantic
            (first wins on duplication) for the scalar JMAP fields.
            """
            occurrences = decoded_by_name.get(name)
            return occurrences[0] if occurrences else ""

        # ─── Per-property JMAP shape construction ───
        # Subject: NFC-normalized text; ``None`` when header absent
        # (preserves spec null-vs-empty distinction).
        jmap_subject = _jmap_subject(
            decoded_by_name["subject"][0] if "subject" in decoded_by_name else None
        )

        # Address fields. Use the raw (surrogate-repaired but NOT RFC
        # 2047-decoded) header values to defend against PortSwigger
        # "Splitting the Email Atom" smuggling.
        truncated_address_lists: set[str] = set()

        def _addrs(name: str) -> list[EmailAddress] | None:
            """Header-present → list (possibly empty after validation);
            header-absent → None. Spec mandates the null-vs-empty
            distinction (RFC 8621 §4.1.2.2)."""
            if name not in decoded_by_name:
                return None
            # ``options=`` must be threaded through: without it every
            # address header is parsed under the module default, so
            # ``max_address_list_bytes`` — the CVE-2024-23184 analogue,
            # Dovecot's unbounded address-list allocation — was inert for
            # anyone tuning it on ``parse_email``, which is the entry
            # point that actually meets hostile mail.
            addresses, truncated = _parse_addresses_ex(
                raw_addr_headers.get(name, ""), options
            )
            if truncated:
                # Entries past the cut are dropped, so the reported list
                # is shorter than the wire's — a recipient we never
                # surface is one a consumer cannot act on.
                truncated_address_lists.add(name)
            return _jmap_addresses(addresses)

        jmap_from = _addrs("from")
        jmap_sender = _addrs("sender")
        jmap_reply_to = _addrs("reply-to")
        jmap_to = _addrs("to")
        jmap_cc = _addrs("cc")
        jmap_bcc = _addrs("bcc")

        # MessageIds (Message-ID, In-Reply-To, References) — String[]
        # with CFWS + <> stripped. ``None`` when absent.
        jmap_message_id = _jmap_message_ids(_first_value("message-id"))
        jmap_in_reply_to = _jmap_message_ids(_first_value("in-reply-to"))
        jmap_references = _jmap_message_ids(_first_value("references"))

        # sentAt: ISO-8601 with offset. ``None`` when ``Date:`` is
        # absent — we do NOT synthesize ``now()`` (anti-conformant).
        jmap_sent_at = _jmap_iso_date(parse_date(_first_value("date")))

        # Defects collected from the stdlib parse + our recursive walks.
        # Populated by the walks below and harvested after body parsing —
        # see the collection site further down for why the order matters.
        defects: list[str] = []

        body_parts = _parse_message_content(message, options=options, defects=defects)
        text_body: list[EmailBodyPart] = body_parts["textBody"]
        html_body: list[EmailBodyPart] = body_parts["htmlBody"]
        attachments: list[EmailBodyPart] = body_parts["attachments"]
        encoding_problems: dict[str, bool] = body_parts.get("encoding_problems") or {}
        has_attachment = _compute_has_attachment(attachments)

        # ─── Assemble the JMAP Email object ───
        result: dict[str, Any] = {
            "subject": jmap_subject,
            "from": jmap_from,
            "sender": jmap_sender,
            "replyTo": jmap_reply_to,
            "to": jmap_to,
            "cc": jmap_cc,
            "bcc": jmap_bcc,
            "messageId": jmap_message_id,
            "inReplyTo": jmap_in_reply_to,
            "references": jmap_references,
            "sentAt": jmap_sent_at,
            "headers": _jmap_headers(wire_headers),
        }

        result["textBody"] = text_body
        result["htmlBody"] = html_body
        result["attachments"] = attachments
        result["hasAttachment"] = has_attachment

        # ─── Body content projections ───
        if preview:
            result["preview"] = _compute_preview(
                text_body,
                html_body,
                options.max_preview_chars,
                options.max_preview_scan_bytes,
            )
        if body_values:
            result["bodyValues"] = _build_body_values(
                text_body, html_body, encoding_problems
            )
            result["textBody"] = _strip_body_part_content(text_body)
            result["htmlBody"] = _strip_body_part_content(html_body)
        if body_structure:
            result["bodyStructure"] = _build_body_structure(message, options)

        # ─── Extensions (project-specific) ───
        if extensions:
            # Harvested HERE, not before the body walk. The stdlib attaches
            # several of its defects while *decoding* a payload rather than
            # while parsing structure — ``InvalidBase64CharactersDefect``,
            # ``InvalidBase64PaddingDefect``, ``InvalidBase64LengthDefect``
            # — so a collection that ran before ``_parse_message_content``
            # (and before ``_build_body_structure``) silently dropped every
            # one of them.
            try:
                for part in message.walk():
                    for defect in getattr(part, "defects", ()) or ():
                        defects.append(type(defect).__name__)
                    _collect_ambiguity_defects(part, defects)
            except RecursionError:
                defects.append("RecursionError")
            _collect_message_ambiguity_defects(message, defects)

            ext: dict[str, Any] = {"defects": defects}
            # Resent-* typed projection. RFC 8621 §4.1.3 names only the
            # 11 base convenience properties; Resent-* is a §4.1.2
            # typed-projection idiom we pre-compute and surface here so
            # forwarded / resent mail handling doesn't have to walk
            # ``parsed["headers"]``. Only emit the sub-dict if any
            # Resent-* header is actually present on the wire.
            resent: dict[str, Any] = {
                "from": _addrs("resent-from"),
                "sender": _addrs("resent-sender"),
                "replyTo": _addrs("resent-reply-to"),
                "to": _addrs("resent-to"),
                "cc": _addrs("resent-cc"),
                "bcc": _addrs("resent-bcc"),
                "messageId": _jmap_message_ids(_first_value("resent-message-id")),
                "date": _jmap_iso_date(parse_date(_first_value("resent-date"))),
            }
            if any(v is not None for v in resent.values()):
                ext["resent"] = resent
            # After the ``resent`` projection: those ``_addrs`` calls feed
            # ``truncated_address_lists`` too, and ``defects`` is aliased
            # into ``ext``, so the append lands in ``_ext.defects``.
            if truncated_address_lists:
                defects.append("AddressListTruncatedDefect")
            result["_ext"] = ext

        return cast(JmapEmail, result)

    except Exception as e:
        logger.exception("parse_email: unexpected error (%s); returning None", e)
        return None
