Files
govoplan-mail/src/govoplan_mail/backend/sending/jmap.py
T
zemion 1cbf4acaf4
Module Package Release / publish-packages (push) Successful in 12s
feat(mail): add governed JMAP mailbox sync and search
2026-08-22 17:09:05 +02:00

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