from __future__ import annotations import imaplib import logging import re import socket import ssl import time from dataclasses import dataclass from email import policy from email.message import EmailMessage from email.parser import BytesParser from typing import Any from govoplan_core.security.outbound_http import ( OutboundHttpError, create_outbound_connection, response_limit, validate_outbound_host, ) from govoplan_mail.backend.config import ImapConfig, TransportSecurity from govoplan_mail.backend.dev.mock_mailbox import ( MOCK_IMAP_FOLDERS, consume_fail_next_imap, get_record, is_mock_imap_host, list_records, record_imap_append, ) logger = logging.getLogger(__name__) class _OutboundPolicyIMAP4(imaplib.IMAP4): def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def] return create_outbound_connection( self.host, self.port, timeout=timeout, label="IMAP connector", ) class _OutboundPolicyIMAP4SSL(imaplib.IMAP4_SSL): def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def] sock = create_outbound_connection( self.host, self.port, timeout=timeout, label="IMAP connector", ) try: return self.ssl_context.wrap_socket(sock, server_hostname=self.host) except Exception: sock.close() raise class ImapConfigurationError(ValueError): """Raised when IMAP settings are incomplete or inconsistent.""" class ImapAppendError(RuntimeError): """Raised when APPENDing to Sent fails. temporary=True means retrying later may help. temporary=False means the configuration or mailbox choice probably needs user/admin attention. """ def __init__( self, message: str, *, temporary: bool | None = None, outcome_unknown: bool = False, ): super().__init__(message) self.temporary = temporary self.outcome_unknown = outcome_unknown @dataclass(frozen=True, slots=True) class ImapLoginTestResult: host: str port: int security: str authenticated: bool @dataclass(frozen=True, slots=True) class ImapMailboxInfo: name: str flags: list[str] message_count: int | None = None unseen_count: int | None = None @dataclass(frozen=True, slots=True) class ImapFolderListResult: host: str port: int security: str folders: list[ImapMailboxInfo] detected_sent_folder: str | None = None @dataclass(frozen=True, slots=True) class ImapMailboxAttachmentInfo: filename: str | None content_type: str size_bytes: int def _log_imap_cleanup_failure(action: str, exc: BaseException) -> None: logger.debug("IMAP cleanup failed while %s: %s", action, exc, exc_info=True) @dataclass(frozen=True, slots=True) class ImapMailboxMessageSummary: uid: str folder: str subject: str | None from_header: str | None to_header: str | None cc_header: str | None date: str | None message_id: str | None flags: list[str] size_bytes: int | None body_preview: str | None attachment_count: int @dataclass(frozen=True, slots=True) class ImapMailboxMessageDetail: uid: str folder: str subject: str | None from_header: str | None to_header: str | None cc_header: str | None date: str | None message_id: str | None flags: list[str] size_bytes: int | None body_preview: str | None body_text: str | None body_html: str | None headers: dict[str, str] attachments: list[ImapMailboxAttachmentInfo] @dataclass(frozen=True, slots=True) class ImapMailboxMessageListResult: host: str port: int security: str folder: str messages: list[ImapMailboxMessageSummary] total_count: int offset: int limit: int uidvalidity: str | None = None cursor_reset: bool = False @dataclass(frozen=True, slots=True) class ImapMailboxBootstrapResult: folders: ImapFolderListResult messages: ImapMailboxMessageListResult @dataclass(frozen=True, slots=True) class ImapMailboxMessageResult: host: str port: int security: str folder: str message: ImapMailboxMessageDetail @dataclass(frozen=True, slots=True) class ImapAppendResult: host: str port: int security: str folder: str bytes_appended: int response: str | None = None def _require_imap_config(config: ImapConfig) -> tuple[str, int]: if not config.host: raise ImapConfigurationError("IMAP host is required") if not config.port: raise ImapConfigurationError("IMAP port is required") if bool(config.username) != bool(config.password): raise ImapConfigurationError("IMAP username and password must be provided together, or both omitted") return config.host, config.port def _open_imap(config: ImapConfig) -> imaplib.IMAP4: host, port = _require_imap_config(config) try: validate_outbound_host(host, port=port, label="IMAP connector") except OutboundHttpError as exc: raise ImapConfigurationError(str(exc)) from exc context = ssl.create_default_context() try: if config.security == TransportSecurity.TLS: client: imaplib.IMAP4 = _OutboundPolicyIMAP4SSL( host=host, port=port, timeout=config.timeout_seconds, ssl_context=context, ) else: client = _OutboundPolicyIMAP4(host=host, port=port, timeout=config.timeout_seconds) if config.security == TransportSecurity.STARTTLS: typ, data = client.starttls(ssl_context=context) if typ != "OK": raise ImapAppendError(f"IMAP STARTTLS failed: {data!r}", temporary=True) if config.username and config.password: typ, data = client.login(config.username, config.password) if typ != "OK": raise ImapAppendError(f"IMAP login failed: {data!r}", temporary=False) return client except Exception: try: client.logout() # type: ignore[possibly-undefined] except Exception as cleanup_exc: _log_imap_cleanup_failure("opening connection", cleanup_exc) raise def _decode_item(item: bytes | str | None) -> str: if item is None: return "" if isinstance(item, bytes): return item.decode("utf-8", errors="replace") return item def _unquote_imap_token(value: str) -> str: value = value.strip() if len(value) >= 2 and value[0] == '"' and value[-1] == '"': value = value[1:-1] value = value.replace('\\"', '"').replace('\\\\', '\\') return value def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None: r"""Best-effort parser for IMAP LIST response lines. RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox name. Some servers quote both delimiter and mailbox:: (\HasNoChildren \Sent) "/" "Sent" Others quote only the delimiter and leave the mailbox as an atom:: (\HasNoChildren \Sent) "/" Sent The parser must therefore parse the delimiter token separately instead of blindly taking the last quoted value. """ line = _decode_item(list_response_line).strip() match = re.match( r'^\((?P[^)]*)\)\s+' r'(?P"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+' r'(?P.+?)\s*$', line, re.IGNORECASE, ) if match: flags = {part.lower() for part in match.group("flags").split()} mailbox = _unquote_imap_token(match.group("mailbox")) if mailbox: return mailbox, flags return None # Fallback for non-standard server lines: prefer the final token. parts = line.split(maxsplit=2) if len(parts) >= 3: flags_text = parts[0].strip("()") flags = {part.lower() for part in flags_text.split()} mailbox = _unquote_imap_token(parts[2]) if mailbox: return mailbox, flags return None def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None: for name, flags in parsed: if "\\sent" in flags or "\\sentmail" in flags: return name common_names = [ "Sent", "Sent Items", "Sent Messages", "Gesendet", "Gesendete Elemente", "INBOX.Sent", "INBOX/Sent", ] names = {name.lower(): name for name, _ in parsed} for candidate in common_names: if candidate.lower() in names: return names[candidate.lower()] return None def discover_sent_folder(client: imaplib.IMAP4) -> str | None: typ, data = client.list() if typ != "OK" or not data: return None parsed: list[tuple[str, set[str]]] = [] for item in data: extracted = _extract_mailbox_name(item) if extracted: parsed.append(extracted) return _detect_sent_folder(parsed) def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str: if requested_folder and requested_folder != "auto": return requested_folder if config.sent_folder and config.sent_folder != "auto": return config.sent_folder discovered = discover_sent_folder(client) if discovered: return discovered raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder") def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult: """Open an IMAP connection and authenticate if credentials are configured. This is a side-effect-free connection test for the WebUI. It does not select a mailbox and does not append any message. """ host, port = _require_imap_config(imap_config) if is_mock_imap_host(imap_config.host): return ImapLoginTestResult( host=host, port=port, security=imap_config.security.value, authenticated=bool(imap_config.username and imap_config.password), ) client = _open_imap(imap_config) try: return ImapLoginTestResult( host=host, port=port, security=imap_config.security.value, authenticated=bool(imap_config.username and imap_config.password), ) finally: try: client.logout() except Exception as cleanup_exc: _log_imap_cleanup_failure("testing login", cleanup_exc) def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: host, port = _require_imap_config(imap_config) records = list_records(limit=500) folders = [] for item in MOCK_IMAP_FOLDERS: name = str(item["name"]) count = sum(1 for record in records if _mock_folder_matches(record, name)) folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None)) return ImapFolderListResult( host=host, port=port, security=imap_config.security.value, folders=folders, detected_sent_folder="Sent", ) def _list_imap_folders_on_client( client: imaplib.IMAP4, *, host: str, port: int, security: str, include_status: bool, ) -> ImapFolderListResult: typ, data = client.list() if typ != "OK": raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True) parsed: list[tuple[str, set[str]]] = [] folders: list[ImapMailboxInfo] = [] for item in data or []: extracted = _extract_mailbox_name(item) if not extracted: continue name, flags = extracted parsed.append((name, flags)) message_count, unseen_count = ( (None, None) if not include_status or _has_folder_flag(flags, "noselect") else _imap_folder_status(client, name) ) folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count)) return ImapFolderListResult( host=host, port=port, security=security, folders=folders, detected_sent_folder=_detect_sent_folder(parsed), ) def list_imap_folders(*, imap_config: ImapConfig, include_status: bool = True) -> ImapFolderListResult: """Return folders visible through IMAP LIST and the best sent-folder guess.""" host, port = _require_imap_config(imap_config) if is_mock_imap_host(imap_config.host): return _mock_imap_folders(imap_config=imap_config) client = _open_imap(imap_config) try: return _list_imap_folders_on_client( client, host=host, port=port, security=imap_config.security.value, include_status=include_status, ) finally: try: client.logout() except Exception as cleanup_exc: _log_imap_cleanup_failure("listing folders", cleanup_exc) def _preferred_mailbox_folder(folders: list[ImapMailboxInfo], requested: str | None, detected_sent_folder: str | None) -> str: folder_names = {folder.name for folder in folders} requested = (requested or "").strip() if requested and requested in folder_names: return requested if "INBOX" in folder_names: return "INBOX" if detected_sent_folder and detected_sent_folder in folder_names: return detected_sent_folder return folders[0].name if folders else (requested or "INBOX") def load_imap_mailbox_bootstrap( *, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0, include_folder_status: bool = False, ) -> ImapMailboxBootstrapResult: """Load folders and one message page through a single IMAP connection.""" host, port = _require_imap_config(imap_config) if is_mock_imap_host(imap_config.host): folders = _mock_imap_folders(imap_config=imap_config) selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder) messages = list_imap_messages(imap_config=imap_config, folder=selected_folder, limit=limit, offset=offset) return ImapMailboxBootstrapResult(folders=folders, messages=messages) client = _open_imap(imap_config) try: folders = _list_imap_folders_on_client( client, host=host, port=port, security=imap_config.security.value, include_status=include_folder_status, ) selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder) selected_folder, limit, offset = _normalize_mailbox_page(folder=selected_folder, limit=limit, offset=offset) messages = _list_imap_messages_on_client( client, host=host, port=port, security=imap_config.security.value, folder=selected_folder, limit=limit, offset=offset, after_uid=None, expected_uidvalidity=None, ) return ImapMailboxBootstrapResult(folders=folders, messages=messages) finally: try: client.logout() except Exception as cleanup_exc: _log_imap_cleanup_failure("bootstrapping mailbox", cleanup_exc) def _list_imap_messages_on_client( client: imaplib.IMAP4, *, host: str, port: int, security: str, folder: str, limit: int, offset: int, after_uid: str | None, expected_uidvalidity: str | None, ) -> ImapMailboxMessageListResult: total_count, uidvalidity = _select_readonly(client, folder) if after_uid is None and expected_uidvalidity is None: page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit) messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder) return ImapMailboxMessageListResult( host=host, port=port, security=security, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit, uidvalidity=None, cursor_reset=False, ) uids = _search_message_uids(client) cursor_reset = False effective_after_uid = after_uid effective_offset = offset if expected_uidvalidity and expected_uidvalidity != uidvalidity: effective_after_uid = None effective_offset = 0 cursor_reset = True page_uids, effective_offset, anchor_missing = _paged_descending_uids( uids, offset=effective_offset, limit=limit, after_uid=effective_after_uid, ) messages = _fetch_message_summaries_by_uid(client, page_uids, folder) return ImapMailboxMessageListResult( host=host, port=port, security=security, folder=folder, messages=messages, total_count=len(uids) if uids else total_count, offset=effective_offset, limit=limit, uidvalidity=uidvalidity, cursor_reset=cursor_reset or anchor_missing, ) def _has_folder_flag(flags: set[str], flag: str) -> bool: wanted = flag.casefold().lstrip("\\") return any(item.casefold().lstrip("\\") == wanted for item in flags) def _quote_mailbox_name(name: str) -> str: return "\"" + name.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" def _imap_folder_status(client: imaplib.IMAP4, folder: str) -> tuple[int | None, int | None]: try: typ, data = client.status(_quote_mailbox_name(folder), "(MESSAGES UNSEEN)") except Exception: return None, None if typ != "OK": return None, None text = " ".join(_decode_item(item) for item in data or []) messages_match = re.search(r"\bMESSAGES\s+(\d+)", text, re.IGNORECASE) unseen_match = re.search(r"\bUNSEEN\s+(\d+)", text, re.IGNORECASE) return ( int(messages_match.group(1)) if messages_match else None, int(unseen_match.group(1)) if unseen_match else None, ) def _parse_message(raw: bytes) -> EmailMessage: return BytesParser(policy=policy.default).parsebytes(raw) def _header_text(message: EmailMessage, name: str) -> str | None: value = message.get(name) return str(value) if value is not None else None def _body_content(message: EmailMessage, prefer: tuple[str, ...]) -> str | None: try: body = message.get_body(preferencelist=prefer) if message.is_multipart() else message if body is None: return None content = body.get_content() except Exception: return None text = str(content).strip() return text or None def _body_preview(message: EmailMessage) -> str | None: text = _body_content(message, ("plain", "html")) if not text: return None preview = re.sub(r"\s+", " ", text).strip() return preview[:600] or None def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]: attachments: list[ImapMailboxAttachmentInfo] = [] for part in message.iter_attachments(): payload = part.get_payload(decode=True) or b"" attachments.append( ImapMailboxAttachmentInfo( filename=part.get_filename(), content_type=part.get_content_type(), size_bytes=len(payload), ) ) return attachments def _message_summary_from_headers(*, uid: str, folder: str, headers: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary: message = _parse_message(headers.rstrip() + b"\r\n\r\n") return ImapMailboxMessageSummary( uid=uid, folder=folder, subject=_header_text(message, "Subject"), from_header=_header_text(message, "From"), to_header=_header_text(message, "To"), cc_header=_header_text(message, "Cc"), date=_header_text(message, "Date"), message_id=_header_text(message, "Message-ID"), flags=flags, size_bytes=size_bytes, body_preview=None, attachment_count=0, ) def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary: message = _parse_message(raw) attachments = _attachment_infos(message) return ImapMailboxMessageSummary( uid=uid, folder=folder, subject=_header_text(message, "Subject"), from_header=_header_text(message, "From"), to_header=_header_text(message, "To"), cc_header=_header_text(message, "Cc"), date=_header_text(message, "Date"), message_id=_header_text(message, "Message-ID"), flags=flags, size_bytes=size_bytes if size_bytes is not None else len(raw), body_preview=_body_preview(message), attachment_count=len(attachments), ) def _message_detail_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageDetail: message = _parse_message(raw) attachments = _attachment_infos(message) return ImapMailboxMessageDetail( uid=uid, folder=folder, subject=_header_text(message, "Subject"), from_header=_header_text(message, "From"), to_header=_header_text(message, "To"), cc_header=_header_text(message, "Cc"), date=_header_text(message, "Date"), message_id=_header_text(message, "Message-ID"), flags=flags, size_bytes=size_bytes if size_bytes is not None else len(raw), body_preview=_body_preview(message), body_text=_body_content(message, ("plain",)), body_html=_body_content(message, ("html",)), headers={str(key): str(value) for key, value in message.items()}, attachments=attachments, ) def _parse_fetch_metadata(metadata: str) -> tuple[str | None, list[str], int | None]: uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE) size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE) flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE) flags = flags_match.group(1).split() if flags_match else [] return uid_match.group(1) if uid_match else None, flags, int(size_match.group(1)) if size_match else None def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]: metadata_parts: list[str] = [] raw: bytes | None = None for item in data or []: if isinstance(item, tuple): metadata_parts.append(_decode_item(item[0])) if isinstance(item[1], bytes): raw = item[1] else: metadata_parts.append(_decode_item(item)) metadata = " ".join(part for part in metadata_parts if part) uid, flags, size_bytes = _parse_fetch_metadata(metadata) if raw is None: raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True) return uid, flags, size_bytes, raw def _parse_fetch_sequence(metadata: str) -> str | None: match = re.match(r"\s*(\d+)\s+\(", metadata) return match.group(1) if match else None def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -> list[tuple[str | None, str | None, list[str], int | None, bytes]]: parts: list[tuple[str | None, str | None, list[str], int | None, bytes]] = [] for item in data or []: if not isinstance(item, tuple) or not isinstance(item[1], bytes): continue metadata = _decode_item(item[0]) sequence = _parse_fetch_sequence(metadata) uid, flags, size_bytes = _parse_fetch_metadata(metadata) parts.append((sequence, uid, flags, size_bytes, item[1])) return parts def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]: typ, data = client.select(_quote_mailbox_name(folder), readonly=True) if typ != "OK": raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) selected_count = _decode_item(data[0] if data else None).strip() try: total_count = max(0, int(selected_count)) except ValueError: total_count = 0 uidvalidity = None try: _, uidvalidity_data = client.response("UIDVALIDITY") if uidvalidity_data: uidvalidity = _decode_item(uidvalidity_data[0]).strip() or None except Exception: uidvalidity = None return total_count, uidvalidity def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]: max_bytes = response_limit("file") typ, data = client.uid("fetch", str(uid), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)") if typ != "OK": raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True) fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data) _ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message") return fetched_uid or str(uid), flags, size_bytes, raw def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]: max_bytes = response_limit("file") typ, data = client.fetch(str(sequence), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)") if typ != "OK": raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True) fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data) _ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message") if not fetched_uid: raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True) return fetched_uid, flags, size_bytes, raw def _sequence_set(sequences: list[str]) -> str: sequence_numbers = sorted({int(sequence) for sequence in sequences if str(sequence).isdigit()}) if not sequence_numbers: return ",".join(str(sequence) for sequence in sequences) expected_range = list(range(sequence_numbers[0], sequence_numbers[-1] + 1)) if sequence_numbers == expected_range: return f"{sequence_numbers[0]}:{sequence_numbers[-1]}" return ",".join(str(sequence) for sequence in sequence_numbers) def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]: if not sequences: return [] max_bytes = response_limit("structured") per_message_bytes = max(1, max_bytes // len(sequences)) + 1 typ, data = client.fetch( _sequence_set(sequences), f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)", ) if typ != "OK": raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True) by_sequence: dict[str, ImapMailboxMessageSummary] = {} parsed_parts = _parse_fetch_parts_with_sequence(data) _ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response") for sequence, fetched_uid, flags, size_bytes, headers in parsed_parts: if not sequence or not fetched_uid: continue by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes) summaries: list[ImapMailboxMessageSummary] = [] for sequence in sequences: summary = by_sequence.get(str(sequence)) if summary is not None: summaries.append(summary) continue fetched_uid, flags, size_bytes, raw = _fetch_message_by_sequence(client, sequence) summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes)) return summaries def _search_message_uids(client: imaplib.IMAP4) -> list[str]: typ, data = client.uid("search", None, "ALL") if typ != "OK": raise ImapAppendError(f"IMAP UID search failed: {data!r}", temporary=True) uids: set[int] = set() for item in data or []: for token in _decode_item(item).split(): if token.isdigit(): uids.add(int(token)) return [str(uid) for uid in sorted(uids, reverse=True)] def _paged_descending_uids( uids: list[str], *, offset: int, limit: int, after_uid: str | None = None, ) -> tuple[list[str], int, bool]: if not uids: return [], 0, False cursor_reset = False if after_uid: try: start = uids.index(str(after_uid)) + 1 except ValueError: start = 0 cursor_reset = True else: start = min(max(0, offset), len(uids)) return uids[start:start + limit], start, cursor_reset def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]: if not uids: return [] max_bytes = response_limit("structured") per_message_bytes = max(1, max_bytes // len(uids)) + 1 typ, data = client.uid( "fetch", _sequence_set(uids), f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)", ) if typ != "OK": raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True) by_uid: dict[str, ImapMailboxMessageSummary] = {} parsed_parts = _parse_fetch_parts_with_sequence(data) _ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response") for _sequence, fetched_uid, flags, size_bytes, headers in parsed_parts: if not fetched_uid: continue by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes) summaries: list[ImapMailboxMessageSummary] = [] for uid in uids: summary = by_uid.get(str(uid)) if summary is not None: summaries.append(summary) continue fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid) summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes)) return summaries def _ensure_imap_payload_within_limit( payload: bytes, *, declared_size: int | None, max_bytes: int, label: str, ) -> None: if (declared_size is not None and declared_size > max_bytes) or len(payload) > max_bytes: raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False) def _ensure_imap_batch_within_limit( parts: list[tuple[str | None, str | None, list[str], int | None, bytes]], *, max_bytes: int, label: str, ) -> None: if sum(len(part[4]) for part in parts) > max_bytes: raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False) def _mock_record_folder(record: dict[str, Any]) -> str: folder = str(record.get("folder") or "").strip() if folder: return folder return "Sent" if record.get("kind") == "imap_append" else "INBOX" def _mock_folder_matches(record: dict[str, Any], folder: str) -> bool: return _mock_record_folder(record).casefold() == folder.casefold() def _mock_raw_bytes(record: dict[str, Any]) -> bytes: raw = record.get("raw_eml") if isinstance(raw, str) and raw: return raw.encode("utf-8", errors="replace") headers = record.get("headers") if isinstance(record.get("headers"), dict) else {} lines = [f"{key}: {value}" for key, value in headers.items()] lines.append("") lines.append(str(record.get("body_preview") or "")) return "\n".join(lines).encode("utf-8", errors="replace") def _mock_mailbox_page_offset( records: list[dict[str, Any]], *, offset: int, after_uid: str | None, expected_uidvalidity: str | None, ) -> tuple[int, bool]: if expected_uidvalidity and expected_uidvalidity != "mock-v1": return 0, True if after_uid: record_ids = [str(record.get("id") or "") for record in records] try: return record_ids.index(str(after_uid)) + 1, False except ValueError: return 0, True return min(offset, len(records)), False def _mock_message_summary(record: dict[str, Any]) -> ImapMailboxMessageSummary: return _message_summary_from_raw( uid=str(record.get("id") or ""), folder=_mock_record_folder(record), raw=_mock_raw_bytes(record), flags=["\\Seen"] if record.get("kind") == "imap_append" else [], size_bytes=int(record.get("size_bytes") or 0) or None, ) def _list_mock_imap_messages( *, host: str, port: int, security: str, folder: str, limit: int, offset: int, after_uid: str | None, expected_uidvalidity: str | None, ) -> ImapMailboxMessageListResult: records = [ record for record in list_records(limit=500) if _mock_folder_matches(record, folder) ] effective_offset, cursor_reset = _mock_mailbox_page_offset( records, offset=offset, after_uid=after_uid, expected_uidvalidity=expected_uidvalidity, ) page_records = records[effective_offset : effective_offset + limit] return ImapMailboxMessageListResult( host=host, port=port, security=security, folder=folder, messages=[_mock_message_summary(record) for record in page_records], total_count=len(records), offset=effective_offset, limit=limit, uidvalidity="mock-v1", cursor_reset=cursor_reset, ) def list_imap_messages( *, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0, after_uid: str | None = None, expected_uidvalidity: str | None = None, ) -> ImapMailboxMessageListResult: """List mailbox messages without mutating read/unread state.""" host, port = _require_imap_config(imap_config) folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=offset) if is_mock_imap_host(imap_config.host): return _list_mock_imap_messages( host=host, port=port, security=imap_config.security.value, folder=folder, limit=limit, offset=offset, after_uid=after_uid, expected_uidvalidity=expected_uidvalidity, ) client = _open_imap(imap_config) try: return _list_imap_messages_on_client( client, host=host, port=port, security=imap_config.security.value, folder=folder, limit=limit, offset=offset, after_uid=after_uid, expected_uidvalidity=expected_uidvalidity, ) finally: try: client.logout() except Exception as cleanup_exc: _log_imap_cleanup_failure("listing messages", cleanup_exc) def _normalize_mailbox_page(*, folder: str | None, limit: int, offset: int) -> tuple[str, int, int]: return (folder or "INBOX").strip() or "INBOX", max(1, min(limit, 100)), max(0, offset) def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]: if total_count <= 0 or offset >= total_count: return [] end = total_count - offset start = max(1, end - limit + 1) return [str(sequence) for sequence in range(end, start - 1, -1)] def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult: """Fetch one message body using BODY.PEEK in a read-only selected mailbox.""" host, port = _require_imap_config(imap_config) folder = (folder or "INBOX").strip() or "INBOX" uid = str(uid).strip() if not uid: raise ImapConfigurationError("Message UID is required") if is_mock_imap_host(imap_config.host): record = get_record(uid, include_raw=True) if not record or not _mock_folder_matches(record, folder): raise ImapAppendError(f"Mock mailbox message {uid!r} was not found in folder {folder!r}", temporary=False) message = _message_detail_from_raw( uid=uid, folder=_mock_record_folder(record), raw=_mock_raw_bytes(record), flags=["\\Seen"] if record.get("kind") == "imap_append" else [], size_bytes=int(record.get("size_bytes") or 0) or None, ) return ImapMailboxMessageResult(host=host, port=port, security=imap_config.security.value, folder=folder, message=message) client = _open_imap(imap_config) try: _select_readonly(client, folder) fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid) message = _message_detail_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes) return ImapMailboxMessageResult(host=host, port=port, security=imap_config.security.value, folder=folder, message=message) finally: try: client.logout() except Exception as cleanup_exc: _log_imap_cleanup_failure("reading message", cleanup_exc) def append_message_to_sent( message_bytes: bytes, *, imap_config: ImapConfig, folder: str | None = None, ) -> ImapAppendResult: """Append a sent MIME message to the configured IMAP Sent folder. The SMTP send remains authoritative. APPEND is a separate best-effort step and should not be used to decide whether an email was sent. """ host, port = _require_imap_config(imap_config) if is_mock_imap_host(imap_config.host): if consume_fail_next_imap(): raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False) target_folder = folder or (imap_config.sent_folder if imap_config.sent_folder and imap_config.sent_folder != "auto" else "Sent") record = record_imap_append(message_bytes, folder=target_folder, imap_host=imap_config.host) return ImapAppendResult( host=host, port=port, security=imap_config.security.value, folder=target_folder, bytes_appended=len(message_bytes), response=f"mock append stored as {record.id}", ) client: imaplib.IMAP4 | None = None append_started = False try: client = _open_imap(imap_config) target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client) internal_date = imaplib.Time2Internaldate(time.time()) append_started = True typ, data = client.append(_quote_mailbox_name(target_folder), "\\Seen", internal_date, message_bytes) if typ != "OK": raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False) response = "; ".join(_decode_item(item) for item in (data or [])) or None return ImapAppendResult( host=host, port=port, security=imap_config.security.value, folder=target_folder, bytes_appended=len(message_bytes), response=response, ) except ImapAppendError: raise except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc: raise ImapAppendError( f"IMAP append failed: {exc}", temporary=not append_started, outcome_unknown=append_started, ) from exc except imaplib.IMAP4.error as exc: raise ImapAppendError( f"IMAP append failed: {exc}", temporary=False, outcome_unknown=append_started, ) from exc finally: if client is not None: try: client.logout() except Exception as cleanup_exc: _log_imap_cleanup_failure("appending sent message", cleanup_exc)