848 lines
28 KiB
Python
848 lines
28 KiB
Python
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
|