from __future__ import annotations import base64 import json import urllib.error import urllib.parse from dataclasses import dataclass from typing import Any, Iterable, Mapping from govoplan_core.security.http_fetch import fetch_http from govoplan_mail.backend.config import JmapConfig from govoplan_mail.backend.sending.imap import ( ImapMailboxAttachmentInfo, ImapMailboxInfo, ImapMailboxMessageDetail, ImapMailboxMessageSummary, ) JMAP_CORE_CAPABILITY = "urn:ietf:params:jmap:core" JMAP_MAIL_CAPABILITY = "urn:ietf:params:jmap:mail" _SUMMARY_PROPERTIES = [ "id", "threadId", "mailboxIds", "keywords", "size", "receivedAt", "sentAt", "messageId", "from", "to", "cc", "subject", "hasAttachment", "preview", ] _DETAIL_PROPERTIES = _SUMMARY_PROPERTIES + [ "replyTo", "bcc", "textBody", "htmlBody", "bodyValues", "attachments", ] class JmapConfigurationError(ValueError): pass class JmapAuthenticationError(RuntimeError): pass class JmapPermissionError(RuntimeError): pass class JmapCapabilityError(RuntimeError): pass class JmapProviderError(RuntimeError): pass @dataclass(frozen=True, slots=True) class JmapSession: api_url: str account_id: str session_state: str capabilities: tuple[str, ...] account_capabilities: tuple[str, ...] username: str | None @dataclass(frozen=True, slots=True) class JmapConnectionTestResult: host: str port: int security: str authenticated: bool account_id: str session_state: str capabilities: tuple[str, ...] @dataclass(frozen=True, slots=True) class JmapFolderListResult: host: str port: int security: str folders: list[ImapMailboxInfo] detected_sent_folder: str | None detected_folder_mappings: dict[str, str] protocol: str = "jmap" @dataclass(frozen=True, slots=True) class JmapMailboxMessageListResult: host: str port: int security: str folder: str messages: list[ImapMailboxMessageSummary] total_count: int offset: int limit: int uidvalidity: str cursor_reset: bool = False @dataclass(frozen=True, slots=True) class JmapMailboxBootstrapResult: folders: JmapFolderListResult messages: JmapMailboxMessageListResult @dataclass(frozen=True, slots=True) class JmapMailboxMessageResult: host: str port: int security: str folder: str message: ImapMailboxMessageDetail @dataclass(frozen=True, slots=True) class JmapEmailChangesResult: account_id: str old_state: str new_state: str has_more_changes: bool created: tuple[str, ...] updated: tuple[str, ...] destroyed: tuple[str, ...] def discover_jmap(config: JmapConfig) -> JmapSession: payload = _fetch_json(config.session_url, config=config, method="GET") capabilities = _string_keys(payload.get("capabilities"), "JMAP capabilities") if JMAP_CORE_CAPABILITY not in capabilities: raise JmapCapabilityError("The server does not advertise the JMAP Core capability") if JMAP_MAIL_CAPABILITY not in capabilities: raise JmapCapabilityError("The server does not advertise the JMAP Mail capability") accounts = _object(payload.get("accounts"), "JMAP accounts") account_id = _select_account_id(payload, accounts, config.account_id) account = _object(accounts.get(account_id), "JMAP account") account_capabilities = _string_keys( account.get("accountCapabilities"), "JMAP account capabilities", ) if JMAP_MAIL_CAPABILITY not in account_capabilities: raise JmapCapabilityError("The selected account does not support JMAP Mail") api_url = _resolve_session_url( config, _required_text(payload.get("apiUrl"), "JMAP Session is missing apiUrl"), label="JMAP apiUrl", ) return JmapSession( api_url=api_url, account_id=account_id, session_state=_required_text( payload.get("state"), "JMAP Session is missing state", ), capabilities=tuple(sorted(capabilities)), account_capabilities=tuple(sorted(account_capabilities)), username=_optional_text(payload.get("username")), ) def test_jmap_connection(*, jmap_config: JmapConfig) -> JmapConnectionTestResult: session = discover_jmap(jmap_config) _jmap_call( jmap_config, session, [("Mailbox/get", {"accountId": session.account_id, "ids": []}, "mailboxes")], ) host, port, security = _transport_coordinates(jmap_config.session_url) return JmapConnectionTestResult( host=host, port=port, security=security, authenticated=True, account_id=session.account_id, session_state=session.session_state, capabilities=session.capabilities, ) def list_jmap_folders(*, jmap_config: JmapConfig) -> JmapFolderListResult: session = discover_jmap(jmap_config) mailboxes = _get_mailboxes(jmap_config, session) paths = _mailbox_paths(mailboxes) folders: list[ImapMailboxInfo] = [] mappings: dict[str, str] = {} for mailbox in sorted(mailboxes, key=lambda item: paths[str(item["id"])].casefold()): mailbox_id = str(mailbox["id"]) path = paths[mailbox_id] role = _optional_text(mailbox.get("role")) flags = [_jmap_role_flag(role)] if role else [] folders.append( ImapMailboxInfo( name=path, flags=[flag for flag in flags if flag], message_count=_optional_nonnegative_int(mailbox.get("totalEmails")), unseen_count=_optional_nonnegative_int(mailbox.get("unreadEmails")), ) ) if role in {"inbox", "sent", "drafts", "trash", "archive", "junk"}: mappings[role] = path host, port, security = _transport_coordinates(jmap_config.session_url) return JmapFolderListResult( host=host, port=port, security=security, folders=folders, detected_sent_folder=mappings.get("sent"), detected_folder_mappings=mappings, ) def list_jmap_messages( *, jmap_config: JmapConfig, folder: str, limit: int = 50, offset: int = 0, expected_query_state: str | None = None, query: str | None = None, ) -> JmapMailboxMessageListResult: clean_limit = max(1, min(int(limit), 100)) clean_offset = max(0, min(int(offset), 100_000)) clean_query = str(query or "").strip() if len(clean_query) > 500: raise JmapConfigurationError("JMAP mailbox search is limited to 500 characters") session = discover_jmap(jmap_config) mailboxes = _get_mailboxes(jmap_config, session) mailbox, paths = _resolve_mailbox(mailboxes, folder) query_payload: dict[str, Any] = { "accountId": session.account_id, "filter": {"inMailbox": mailbox["id"]}, "sort": [{"property": "receivedAt", "isAscending": False}], "position": clean_offset, "limit": clean_limit, "calculateTotal": True, } if clean_query: query_payload["filter"] = { "operator": "AND", "conditions": [ {"inMailbox": mailbox["id"]}, {"text": clean_query}, ], } query_result = _method_result( _jmap_call( jmap_config, session, [("Email/query", query_payload, "query")], ), name="Email/query", call_id="query", ) query_state = _required_text( query_result.get("queryState"), "JMAP Email/query response is missing queryState", ) cursor_reset = bool(expected_query_state and expected_query_state != query_state) if cursor_reset and clean_offset: clean_offset = 0 query_payload["position"] = 0 query_result = _method_result( _jmap_call( jmap_config, session, [("Email/query", query_payload, "query-reset")], ), name="Email/query", call_id="query-reset", ) query_state = _required_text( query_result.get("queryState"), "JMAP Email/query response is missing queryState", ) ids = _string_list(query_result.get("ids"), "JMAP Email/query ids", maximum=100) emails: list[dict[str, Any]] = [] if ids: get_result = _method_result( _jmap_call( jmap_config, session, [( "Email/get", { "accountId": session.account_id, "ids": ids, "properties": _SUMMARY_PROPERTIES, }, "emails", )], ), name="Email/get", call_id="emails", ) emails = _object_list(get_result.get("list"), "JMAP Email/get list", maximum=100) by_id = {str(item.get("id")): item for item in emails if item.get("id") is not None} folder_path = paths[str(mailbox["id"])] messages = [ _email_summary(by_id[email_id], folder=folder_path) for email_id in ids if email_id in by_id ] host, port, security = _transport_coordinates(jmap_config.session_url) total = query_result.get("total") total_count = int(total) if isinstance(total, int) and total >= 0 else clean_offset + len(messages) return JmapMailboxMessageListResult( host=host, port=port, security=security, folder=folder_path, messages=messages, total_count=total_count, offset=clean_offset, limit=clean_limit, uidvalidity=query_state, cursor_reset=cursor_reset, ) def load_jmap_mailbox_bootstrap( *, jmap_config: JmapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0, ) -> JmapMailboxBootstrapResult: folders = list_jmap_folders(jmap_config=jmap_config) selected = folder names = {item.name for item in folders.folders} if selected not in names: selected = ( folders.detected_folder_mappings.get("inbox") or (folders.folders[0].name if folders.folders else folder) ) messages = list_jmap_messages( jmap_config=jmap_config, folder=selected, limit=limit, offset=offset, ) return JmapMailboxBootstrapResult(folders=folders, messages=messages) def get_jmap_message( *, jmap_config: JmapConfig, folder: str, email_id: str, ) -> JmapMailboxMessageResult: clean_id = _required_text(email_id, "JMAP Email id is required") if len(clean_id) > 255: raise JmapConfigurationError("JMAP Email id is too long") session = discover_jmap(jmap_config) mailbox, paths = _resolve_mailbox(_get_mailboxes(jmap_config, session), folder) mailbox_id = str(mailbox["id"]) canonical_folder = paths[mailbox_id] result = _method_result( _jmap_call( jmap_config, session, [( "Email/get", { "accountId": session.account_id, "ids": [clean_id], "properties": _DETAIL_PROPERTIES, "bodyProperties": [ "partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", ], "fetchTextBodyValues": True, "fetchHTMLBodyValues": True, "maxBodyValueBytes": jmap_config.max_body_value_bytes, }, "email", )], ), name="Email/get", call_id="email", ) values = _object_list(result.get("list"), "JMAP Email/get list", maximum=1) if not values: raise JmapProviderError("JMAP message not found") email = values[0] mailbox_ids = email.get("mailboxIds") if not isinstance(mailbox_ids, dict) or mailbox_ids.get(mailbox_id) is not True: raise JmapProviderError("JMAP message is not available in the requested mailbox") summary = _email_summary(email, folder=canonical_folder) body_values = _object(email.get("bodyValues") or {}, "JMAP Email bodyValues") body_text = _body_value(email.get("textBody"), body_values) body_html = _body_value(email.get("htmlBody"), body_values) attachments = [ ImapMailboxAttachmentInfo( filename=_optional_text(item.get("name")), content_type=_optional_text(item.get("type")) or "application/octet-stream", size_bytes=_optional_nonnegative_int(item.get("size")) or 0, ) for item in _object_list( email.get("attachments") or [], "JMAP Email attachments", maximum=1_000, ) ] headers = { key: value for key, value in { "From": summary.from_header, "To": summary.to_header, "Cc": summary.cc_header, "Bcc": _format_addresses(email.get("bcc")), "Reply-To": _format_addresses(email.get("replyTo")), "Message-ID": summary.message_id, "Date": summary.date, "Subject": summary.subject, }.items() if value } detail = ImapMailboxMessageDetail( uid=summary.uid, folder=summary.folder, subject=summary.subject, from_header=summary.from_header, to_header=summary.to_header, cc_header=summary.cc_header, date=summary.date, message_id=summary.message_id, flags=summary.flags, size_bytes=summary.size_bytes, body_preview=summary.body_preview, body_text=body_text, body_html=body_html, headers=headers, attachments=attachments, ) host, port, security = _transport_coordinates(jmap_config.session_url) return JmapMailboxMessageResult( host=host, port=port, security=security, folder=canonical_folder, message=detail, ) def get_jmap_email_changes( *, jmap_config: JmapConfig, since_state: str, max_changes: int = 500, ) -> JmapEmailChangesResult: clean_state = _required_text(since_state, "JMAP Email change state is required") if len(clean_state) > 1_000: raise JmapConfigurationError("JMAP Email change state is too long") clean_max = max(1, min(int(max_changes), 1_000)) session = discover_jmap(jmap_config) result = _method_result( _jmap_call( jmap_config, session, [( "Email/changes", { "accountId": session.account_id, "sinceState": clean_state, "maxChanges": clean_max, }, "changes", )], ), name="Email/changes", call_id="changes", ) return JmapEmailChangesResult( account_id=session.account_id, old_state=_required_text(result.get("oldState"), "JMAP changes is missing oldState"), new_state=_required_text(result.get("newState"), "JMAP changes is missing newState"), has_more_changes=bool(result.get("hasMoreChanges")), created=tuple(_string_list(result.get("created"), "JMAP created ids", maximum=clean_max)), updated=tuple(_string_list(result.get("updated"), "JMAP updated ids", maximum=clean_max)), destroyed=tuple(_string_list(result.get("destroyed"), "JMAP destroyed ids", maximum=clean_max)), ) def _get_mailboxes(config: JmapConfig, session: JmapSession) -> list[dict[str, Any]]: result = _method_result( _jmap_call( config, session, [( "Mailbox/get", { "accountId": session.account_id, "properties": [ "id", "name", "parentId", "role", "sortOrder", "isSubscribed", "totalEmails", "unreadEmails", ], }, "mailboxes", )], ), name="Mailbox/get", call_id="mailboxes", ) rows = _object_list(result.get("list"), "JMAP Mailbox/get list", maximum=10_000) for row in rows: _required_text(row.get("id"), "JMAP Mailbox is missing id") _required_text(row.get("name"), "JMAP Mailbox is missing name") return rows def _jmap_call( config: JmapConfig, session: JmapSession, calls: Iterable[tuple[str, Mapping[str, Any], str]], ) -> dict[str, Any]: method_calls = [[name, dict(arguments), call_id] for name, arguments, call_id in calls] if not method_calls or len(method_calls) > 32: raise JmapConfigurationError("A JMAP request must contain between 1 and 32 method calls") body = json.dumps( { "using": [JMAP_CORE_CAPABILITY, JMAP_MAIL_CAPABILITY], "methodCalls": method_calls, }, separators=(",", ":"), ).encode("utf-8") return _fetch_json(session.api_url, config=config, method="POST", body=body) def _fetch_json( url: str, *, config: JmapConfig, method: str, body: bytes | None = None, ) -> dict[str, Any]: try: response = fetch_http( url, timeout=config.timeout_seconds, label="JMAP endpoint", method=method, headers={ "Accept": "application/json", "Authorization": _authorization_header(config), **({"Content-Type": "application/json"} if body is not None else {}), }, body=body, max_bytes=config.max_response_bytes, ) except urllib.error.HTTPError as exc: if exc.code == 401: raise JmapAuthenticationError("JMAP authentication failed") from exc if exc.code == 403: raise JmapPermissionError("JMAP access is forbidden for this credential") from exc raise JmapProviderError(f"JMAP provider returned HTTP {exc.code}") from exc except (JmapAuthenticationError, JmapPermissionError, JmapProviderError): raise except Exception as exc: raise JmapProviderError("JMAP provider is unavailable") from exc try: payload = json.loads(response.body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise JmapProviderError("JMAP provider returned invalid JSON") from exc return _object(payload, "JMAP response") def _method_result(payload: Mapping[str, Any], *, name: str, call_id: str) -> dict[str, Any]: responses = payload.get("methodResponses") if not isinstance(responses, list): raise JmapProviderError("JMAP response is missing methodResponses") for item in responses: if not isinstance(item, list) or len(item) != 3: continue response_name, arguments, response_id = item if response_id != call_id: continue if response_name == "error": error = _object(arguments, "JMAP method error") error_type = _optional_text(error.get("type")) or "unknown" if error_type in {"accountNotFound", "forbidden"}: raise JmapPermissionError(f"JMAP {name} was denied ({error_type})") if error_type in {"unknownMethod", "unknownCapability"}: raise JmapCapabilityError(f"JMAP {name} is unsupported ({error_type})") if error_type == "cannotCalculateChanges": raise JmapCapabilityError("JMAP incremental state expired; perform a full refresh") raise JmapProviderError(f"JMAP {name} failed ({error_type})") if response_name != name: raise JmapProviderError(f"JMAP returned {response_name!r} for {name}") return _object(arguments, f"JMAP {name} response") raise JmapProviderError(f"JMAP response did not include call {call_id!r}") def _select_account_id( session_payload: Mapping[str, Any], accounts: Mapping[str, Any], configured: str | None, ) -> str: if configured: if configured not in accounts: raise JmapPermissionError("The configured JMAP account is not available") return configured primary = session_payload.get("primaryAccounts") if isinstance(primary, dict) and primary.get(JMAP_MAIL_CAPABILITY): account_id = str(primary[JMAP_MAIL_CAPABILITY]) if account_id in accounts: return account_id capable = [ str(account_id) for account_id, value in accounts.items() if isinstance(value, dict) and JMAP_MAIL_CAPABILITY in _string_keys(value.get("accountCapabilities"), "JMAP account capabilities") ] if len(capable) == 1: return capable[0] if not capable: raise JmapCapabilityError("No accessible account supports JMAP Mail") raise JmapConfigurationError("Configure a JMAP account id because multiple mail accounts are available") def _resolve_session_url(config: JmapConfig, value: str, *, label: str) -> str: candidate = urllib.parse.urljoin(config.session_url, value) candidate_origin = _origin(candidate) allowed = {_origin(config.session_url), *config.allowed_api_origins} if candidate_origin not in allowed: raise JmapConfigurationError( f"{label} uses unapproved origin {candidate_origin}; add it to allowed_api_origins" ) return candidate def _authorization_header(config: JmapConfig) -> str: if config.auth_scheme == "bearer": return f"Bearer {config.password}" raw = f"{config.username}:{config.password}".encode("utf-8") return f"Basic {base64.b64encode(raw).decode('ascii')}" def _transport_coordinates(url: str) -> tuple[str, int, str]: parsed = urllib.parse.urlsplit(url) return ( parsed.hostname or "", parsed.port or (443 if parsed.scheme == "https" else 80), parsed.scheme, ) def _origin(value: str) -> str: parsed = urllib.parse.urlsplit(value) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise JmapConfigurationError("JMAP Session advertised an invalid HTTP(S) URL") if parsed.username or parsed.password or parsed.fragment: raise JmapConfigurationError("JMAP Session advertised an unsafe URL") port = parsed.port or (443 if parsed.scheme == "https" else 80) default = 443 if parsed.scheme == "https" else 80 suffix = "" if port == default else f":{port}" return f"{parsed.scheme.lower()}://{parsed.hostname.lower()}{suffix}" def _mailbox_paths(mailboxes: list[dict[str, Any]]) -> dict[str, str]: by_id = {str(item["id"]): item for item in mailboxes} paths: dict[str, str] = {} def path_for(mailbox_id: str, stack: tuple[str, ...] = ()) -> str: if mailbox_id in paths: return paths[mailbox_id] if mailbox_id in stack: raise JmapProviderError("JMAP mailbox hierarchy contains a cycle") mailbox = by_id[mailbox_id] name = _required_text(mailbox.get("name"), "JMAP Mailbox is missing name") parent_id = _optional_text(mailbox.get("parentId")) if parent_id and parent_id in by_id: value = f"{path_for(parent_id, (*stack, mailbox_id))}/{name}" else: value = name paths[mailbox_id] = value return value for mailbox_id in by_id: path_for(mailbox_id) return paths def _resolve_mailbox( mailboxes: list[dict[str, Any]], folder: str, ) -> tuple[dict[str, Any], dict[str, str]]: paths = _mailbox_paths(mailboxes) clean = str(folder or "INBOX").strip() for item in mailboxes: mailbox_id = str(item["id"]) role = _optional_text(item.get("role")) if mailbox_id == clean or paths[mailbox_id] == clean: return item, paths if clean.casefold() == "inbox" and role == "inbox": return item, paths raise JmapConfigurationError(f"JMAP mailbox {clean!r} is not available") def _jmap_role_flag(role: str | None) -> str: return { "inbox": "\\Inbox", "sent": "\\Sent", "drafts": "\\Drafts", "trash": "\\Trash", "archive": "\\Archive", "junk": "\\Junk", }.get(role or "", "") def _email_summary(email: Mapping[str, Any], *, folder: str) -> ImapMailboxMessageSummary: email_id = _required_text(email.get("id"), "JMAP Email is missing id") message_ids = email.get("messageId") message_id = None if isinstance(message_ids, list) and message_ids: message_id = _optional_text(message_ids[0]) elif isinstance(message_ids, str): message_id = _optional_text(message_ids) keywords = email.get("keywords") if isinstance(email.get("keywords"), dict) else {} flags = [ flag for keyword, flag in ( ("$seen", "\\Seen"), ("$flagged", "\\Flagged"), ("$answered", "\\Answered"), ("$draft", "\\Draft"), ) if keywords.get(keyword) is True ] return ImapMailboxMessageSummary( uid=email_id, folder=folder, subject=_optional_text(email.get("subject")), from_header=_format_addresses(email.get("from")), to_header=_format_addresses(email.get("to")), cc_header=_format_addresses(email.get("cc")), date=_optional_text(email.get("receivedAt")) or _optional_text(email.get("sentAt")), message_id=message_id, flags=flags, size_bytes=_optional_nonnegative_int(email.get("size")), body_preview=_optional_text(email.get("preview")), attachment_count=(1 if email.get("hasAttachment") is True else 0), ) def _format_addresses(value: object) -> str | None: if not isinstance(value, list): return None parts: list[str] = [] for item in value[:1_000]: if not isinstance(item, dict): continue name = _optional_text(item.get("name")) email = _optional_text(item.get("email")) if name and email: parts.append(f"{name} <{email}>") elif email or name: parts.append(email or name or "") return ", ".join(parts) or None def _body_value(parts: object, values: Mapping[str, Any]) -> str | None: if not isinstance(parts, list): return None result: list[str] = [] for part in parts[:1_000]: if not isinstance(part, dict): continue part_id = _optional_text(part.get("partId")) body = values.get(part_id) if part_id else None if isinstance(body, dict) and isinstance(body.get("value"), str): result.append(body["value"]) return "\n".join(result) or None def _object(value: object, label: str) -> dict[str, Any]: if not isinstance(value, dict): raise JmapProviderError(f"{label} must be an object") return dict(value) def _object_list(value: object, label: str, *, maximum: int) -> list[dict[str, Any]]: if not isinstance(value, list) or len(value) > maximum: raise JmapProviderError(f"{label} must be an array with at most {maximum} items") if not all(isinstance(item, dict) for item in value): raise JmapProviderError(f"{label} contains an invalid item") return [dict(item) for item in value] def _string_keys(value: object, label: str) -> set[str]: if not isinstance(value, dict): raise JmapProviderError(f"{label} must be an object") return {str(key) for key in value} def _string_list(value: object, label: str, *, maximum: int) -> list[str]: if not isinstance(value, list) or len(value) > maximum: raise JmapProviderError(f"{label} must be an array with at most {maximum} items") if not all(isinstance(item, str) and item for item in value): raise JmapProviderError(f"{label} contains an invalid id") return list(value) def _required_text(value: object, message: str) -> str: text = str(value).strip() if isinstance(value, str) else "" if not text: raise JmapProviderError(message) return text def _optional_text(value: object) -> str | None: if not isinstance(value, str): return None return value.strip() or None def _optional_nonnegative_int(value: object) -> int | None: return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None