from __future__ import annotations import hashlib import poplib import socket import ssl from dataclasses import dataclass from email import policy from email.message import Message from email.parser import BytesParser from typing import Iterable from govoplan_core.security.outbound_http import ( OutboundHttpError, create_outbound_connection, validate_outbound_host, ) from govoplan_mail.backend.config import Pop3Config, TransportSecurity class _OutboundPolicyPOP3(poplib.POP3): def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def] return create_outbound_connection( self.host, self.port, timeout=timeout, label="POP3 legacy import", ) class _OutboundPolicyPOP3SSL(poplib.POP3_SSL): def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def] sock = create_outbound_connection( self.host, self.port, timeout=timeout, label="POP3 legacy import", ) try: return self.context.wrap_socket(sock, server_hostname=self.host) except Exception: sock.close() raise class Pop3ConfigurationError(ValueError): pass class Pop3ProviderError(RuntimeError): def __init__(self, message: str, *, outcome_unknown: bool = False): super().__init__(message) self.outcome_unknown = outcome_unknown @dataclass(frozen=True, slots=True) class Pop3LoginTestResult: host: str port: int security: str authenticated: bool message_count: int mailbox_size_bytes: int @dataclass(frozen=True, slots=True) class Pop3MessageSummary: message_number: int uidl: str subject: str | None from_header: str | None to_header: str | None date: str | None message_id: str | None size_bytes: int body_preview: str | None @dataclass(frozen=True, slots=True) class Pop3PreviewResult: host: str port: int security: str message_count: int mailbox_size_bytes: int messages: tuple[Pop3MessageSummary, ...] @dataclass(frozen=True, slots=True) class Pop3DownloadedMessage: message_number: int uidl: str raw: bytes raw_sha256: str summary: Pop3MessageSummary @dataclass(frozen=True, slots=True) class Pop3DeletionResult: deleted_uidls: tuple[str, ...] def _require_pop3_config(config: Pop3Config) -> tuple[str, int]: if not config.legacy_import_enabled: raise Pop3ConfigurationError( "POP3 legacy import is disabled for the selected server" ) if not config.host: raise Pop3ConfigurationError("POP3 host is required") if not config.port: raise Pop3ConfigurationError("POP3 port is required") if not config.username or not config.password: raise Pop3ConfigurationError("POP3 username and password are required") return config.host, config.port def _open_pop3(config: Pop3Config) -> poplib.POP3: host, port = _require_pop3_config(config) try: validate_outbound_host(host, port=port, label="POP3 legacy import") except OutboundHttpError as exc: raise Pop3ConfigurationError(str(exc)) from exc context = ssl.create_default_context() client: poplib.POP3 | None = None try: if config.security == TransportSecurity.TLS: client = _OutboundPolicyPOP3SSL( host=host, port=port, timeout=config.timeout_seconds, context=context, ) else: client = _OutboundPolicyPOP3( host=host, port=port, timeout=config.timeout_seconds, ) if config.security == TransportSecurity.STARTTLS: client.stls(context=context) client.user(config.username) client.pass_(config.password) return client except ssl.SSLError as exc: _close_without_commit(client) raise Pop3ProviderError("POP3 TLS negotiation failed") from exc except poplib.error_proto as exc: _close_without_commit(client) raise Pop3ProviderError("POP3 authentication failed") from exc except (OSError, socket.error) as exc: _close_without_commit(client) raise Pop3ProviderError("POP3 connection failed") from exc except Exception: _close_without_commit(client) raise def test_pop3_login(*, pop3_config: Pop3Config) -> Pop3LoginTestResult: client = _open_pop3(pop3_config) try: message_count, mailbox_size = client.stat() return Pop3LoginTestResult( host=str(pop3_config.host), port=int(pop3_config.port or 0), security=pop3_config.security.value, authenticated=True, message_count=int(message_count), mailbox_size_bytes=int(mailbox_size), ) except poplib.error_proto as exc: raise Pop3ProviderError("POP3 mailbox statistics are unavailable") from exc finally: _quit_without_deletions(client) def preview_pop3_messages( *, pop3_config: Pop3Config, limit: int = 50, ) -> Pop3PreviewResult: clean_limit = max(1, min(int(limit), 100)) client = _open_pop3(pop3_config) try: message_count, mailbox_size = client.stat() uidls = _uidl_map(client) sizes = _size_map(client) selected_numbers = sorted(uidls, reverse=True)[:clean_limit] messages = tuple( _preview_message( client, message_number=number, uidl=uidls[number], size_bytes=sizes.get(number, 0), body_lines=pop3_config.preview_body_lines, max_message_bytes=pop3_config.max_message_bytes, ) for number in selected_numbers ) return Pop3PreviewResult( host=str(pop3_config.host), port=int(pop3_config.port or 0), security=pop3_config.security.value, message_count=int(message_count), mailbox_size_bytes=int(mailbox_size), messages=messages, ) except poplib.error_proto as exc: raise Pop3ProviderError("POP3 message preview failed") from exc finally: _quit_without_deletions(client) def download_pop3_messages( *, pop3_config: Pop3Config, uidls: Iterable[str], ) -> tuple[Pop3DownloadedMessage, ...]: selected_uidls = tuple(dict.fromkeys(_required_uidl(value) for value in uidls)) if not selected_uidls: raise Pop3ConfigurationError("Select at least one POP3 message to import") if len(selected_uidls) > 100: raise Pop3ConfigurationError("At most 100 POP3 messages can be imported at once") client = _open_pop3(pop3_config) try: uidl_by_number = _uidl_map(client) number_by_uidl = {uidl: number for number, uidl in uidl_by_number.items()} missing = [uidl for uidl in selected_uidls if uidl not in number_by_uidl] if missing: raise Pop3ProviderError( "One or more previewed POP3 messages are no longer available; refresh the preview" ) sizes = _size_map(client) advertised_batch_size = sum( max(0, int(sizes.get(number_by_uidl[uidl], 0))) for uidl in selected_uidls ) if advertised_batch_size > pop3_config.max_batch_bytes: raise Pop3ProviderError( "The selected POP3 messages exceed the configured batch size limit" ) downloaded: list[Pop3DownloadedMessage] = [] downloaded_bytes = 0 for uidl in selected_uidls: number = number_by_uidl[uidl] advertised_size = sizes.get(number, 0) if advertised_size > pop3_config.max_message_bytes: raise Pop3ProviderError( f"POP3 message {uidl} exceeds the configured import size limit" ) _response, lines, _octets = client.retr(number) raw = _message_bytes(lines) if len(raw) > pop3_config.max_message_bytes: raise Pop3ProviderError( f"POP3 message {uidl} exceeds the configured import size limit" ) downloaded_bytes += len(raw) if downloaded_bytes > pop3_config.max_batch_bytes: raise Pop3ProviderError( "The selected POP3 messages exceed the configured batch size limit" ) summary = _message_summary( raw, message_number=number, uidl=uidl, size_bytes=len(raw), ) downloaded.append( Pop3DownloadedMessage( message_number=number, uidl=uidl, raw=raw, raw_sha256=hashlib.sha256(raw).hexdigest(), summary=summary, ) ) return tuple(downloaded) except poplib.error_proto as exc: raise Pop3ProviderError("POP3 message download failed") from exc finally: _quit_without_deletions(client) def delete_pop3_messages( *, pop3_config: Pop3Config, uidls: Iterable[str], ) -> Pop3DeletionResult: selected_uidls = tuple(dict.fromkeys(_required_uidl(value) for value in uidls)) if not selected_uidls: return Pop3DeletionResult(deleted_uidls=()) if not pop3_config.allow_delete_after_import: raise Pop3ConfigurationError( "POP3 delete-after-import is disabled for the selected server" ) client = _open_pop3(pop3_config) quit_started = False try: number_by_uidl = { uidl: number for number, uidl in _uidl_map(client).items() } missing = [uidl for uidl in selected_uidls if uidl not in number_by_uidl] if missing: raise Pop3ProviderError( "One or more imported POP3 messages are no longer available for deletion" ) for uidl in selected_uidls: client.dele(number_by_uidl[uidl]) quit_started = True client.quit() return Pop3DeletionResult(deleted_uidls=selected_uidls) except Pop3ProviderError: _close_without_commit(client) raise except poplib.error_proto as exc: _close_without_commit(client) raise Pop3ProviderError( "POP3 deletion outcome is unknown" if quit_started else "POP3 deletion was rejected", outcome_unknown=quit_started, ) from exc except Exception as exc: _close_without_commit(client) raise Pop3ProviderError( "POP3 deletion outcome is unknown" if quit_started else "POP3 deletion failed", outcome_unknown=quit_started, ) from exc def _uidl_map(client: poplib.POP3) -> dict[int, str]: _response, lines, _octets = client.uidl() result: dict[int, str] = {} for raw_line in lines: parts = bytes(raw_line).decode("utf-8", errors="replace").split(maxsplit=1) if len(parts) != 2 or not parts[0].isdigit(): continue uidl = _required_uidl(parts[1]) result[int(parts[0])] = uidl if not result: raise Pop3ProviderError( "The POP3 server does not provide stable UIDL identifiers; safe import is unavailable" ) return result def _size_map(client: poplib.POP3) -> dict[int, int]: _response, lines, _octets = client.list() result: dict[int, int] = {} for raw_line in lines: parts = bytes(raw_line).decode("ascii", errors="ignore").split(maxsplit=1) if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit(): result[int(parts[0])] = int(parts[1]) return result def _preview_message( client: poplib.POP3, *, message_number: int, uidl: str, size_bytes: int, body_lines: int, max_message_bytes: int, ) -> Pop3MessageSummary: raw: bytes | None = None try: _response, lines, _octets = client.top(message_number, body_lines) raw = _message_bytes(lines) except (poplib.error_proto, AttributeError): # TOP is optional. Never use RETR as a preview fallback when the # advertised message already exceeds the configured download bound. if size_bytes > max_message_bytes: return Pop3MessageSummary( message_number=message_number, uidl=uidl, subject=None, from_header=None, to_header=None, date=None, message_id=None, size_bytes=size_bytes, body_preview=None, ) _response, lines, _octets = client.retr(message_number) raw = _message_bytes(lines) if len(raw) > min(max_message_bytes, 256 * 1024): return Pop3MessageSummary( message_number=message_number, uidl=uidl, subject=None, from_header=None, to_header=None, date=None, message_id=None, size_bytes=size_bytes, body_preview=None, ) return _message_summary( raw, message_number=message_number, uidl=uidl, size_bytes=size_bytes, ) def _message_summary( raw: bytes, *, message_number: int, uidl: str, size_bytes: int, ) -> Pop3MessageSummary: message = BytesParser(policy=policy.default).parsebytes(raw) return Pop3MessageSummary( message_number=message_number, uidl=uidl, subject=_header(message, "Subject"), from_header=_header(message, "From"), to_header=_header(message, "To"), date=_header(message, "Date"), message_id=_header(message, "Message-ID"), size_bytes=max(0, int(size_bytes)), body_preview=_body_preview(message), ) def _header(message: Message, name: str) -> str | None: value = message.get(name) if value is None: return None text = str(value).strip() return text[:2_000] or None def _body_preview(message: Message) -> str | None: body = message.get_body(preferencelist=("plain",)) if message.is_multipart() else message if body is None: return None try: text = body.get_content() except Exception: payload = body.get_payload(decode=True) text = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else str(payload or "") normalized = " ".join(str(text).split()) return normalized[:500] or None def _message_bytes(lines: Iterable[bytes]) -> bytes: return b"\r\n".join(bytes(line) for line in lines) + b"\r\n" def _required_uidl(value: object) -> str: clean = str(value or "").strip() if not clean or len(clean) > 500 or any(char.isspace() for char in clean): raise Pop3ConfigurationError("POP3 UIDL must be a non-empty token") return clean def _quit_without_deletions(client: poplib.POP3) -> None: try: client.quit() except Exception: _close_without_commit(client) def _close_without_commit(client: poplib.POP3 | None) -> None: if client is None: return try: client.rset() except Exception: pass try: client.close() except Exception: pass __all__ = [ "Pop3ConfigurationError", "Pop3DeletionResult", "Pop3DownloadedMessage", "Pop3LoginTestResult", "Pop3MessageSummary", "Pop3PreviewResult", "Pop3ProviderError", "delete_pop3_messages", "download_pop3_messages", "preview_pop3_messages", "test_pop3_login", ]