Source code for calendaring_jmap.objects.contact
# SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
JMAP Contacts objects (:rfc:`9610`).
AddressBook and ContactCard, as returned by ``AddressBook/get`` and
``ContactCard/get``. ContactCard properties follow JSContact (:rfc:`9553`).
Rights maps (``share_with``, ``my_rights``) and the raw JSContact ``name``/
``emails`` dicts are all kept as untyped dicts rather than typed
sub-objects, matching :class:`~calendaring_jmap.objects.calendar.JMAPCalendar`'s
own ``share_with``/``my_rights`` convention.
"""
from __future__ import annotations
from dataclasses import dataclass, field
#: Sort key for an EmailAddress with no explicit ``pref`` (RFC 9553 section 1.5.3
#: caps the real range at 1-100; one past that sorts after every explicit
#: value, matching "no preference set" being least preferred of all).
_UNSET_PREF_SORT_KEY = 101
[docs]
@dataclass
class JMAPAddressBook:
"""A JMAP AddressBook object (:rfc:`9610#section-2`).
Attributes:
id: Server-assigned identifier.
name: Display name, non-empty, at most 255 UTF-8 octets.
description: Optional longer description.
sort_order: Hint for display ordering (lower = first). Default 0.
is_default: True for at most one address book in the account
(:rfc:`9610#section-2` says this SHOULD be true for exactly
one, but only MUST NOT be true for more than one), server-set.
is_subscribed: Whether the user is subscribed to this address book.
share_with: Map of Principal id (:rfc:`9670#section-2`) to a dict of
AddressBookRights (``mayRead``, ``mayWrite``, ``mayShare``,
``mayDelete``). ``None`` if unshared or the server lacks
:rfc:`9670` support (:rfc:`9610#section-2`), verified against
Cyrus. Stalwart behaves differently: it returns ``{}`` here
instead when unshared, not ``None``, found by testing directly
against it. A caller checking "is this shared" should treat
the value as falsy
(``not address_book.share_with``), not test for ``is None``
specifically.
my_rights: AddressBookRights dict for the current user, server-set.
"""
id: str
name: str
description: str | None = None
sort_order: int = 0
is_default: bool = False
is_subscribed: bool = True
share_with: dict | None = None
my_rights: dict = field(default_factory=dict)
[docs]
@classmethod
def from_jmap(cls, data: dict) -> JMAPAddressBook:
"""Construct a JMAPAddressBook from a raw JMAP AddressBook JSON dict.
Unknown keys in ``data`` are silently ignored so that forward
compatibility is maintained as the spec evolves.
"""
return cls(
id=data["id"],
name=data["name"],
description=data.get("description"),
sort_order=data.get("sortOrder", 0),
is_default=data.get("isDefault", False),
is_subscribed=data.get("isSubscribed", True),
share_with=data.get("shareWith"),
my_rights=data.get("myRights", {}),
)
[docs]
@dataclass
class JMAPContact:
"""A JMAP ContactCard object (:rfc:`9610#section-3`), a JSContact Card
(:rfc:`9553#section-2`) plus JMAP's own ``id``/``addressBookIds``.
Attributes:
id: Server-assigned identifier. May differ from ``uid``.
uid: The card's own JSContact uid (:rfc:`9553#section-2.1.9`).
That section marks it mandatory on every Card, but confirmed
live that Stalwart's ``ContactCard/get`` never returns it at
all, even when explicitly requested via ``properties``. Unlike
``id``, this is not required here: a strict-missing check would
raise on every Stalwart contact. Cyrus does return it.
address_book_ids: Map of AddressBook id to ``True``; a card belongs
to at least one address book.
kind: JSContact Card kind (:rfc:`9553#section-2.1.4`): one of
``"individual"`` (default), ``"group"``, ``"org"``,
``"location"``, ``"device"``, ``"application"``.
name: Raw JSContact Name dict (:rfc:`9553#section-2.2.1`), or
``None``. Its own shape (``full`` vs. ``components``) is read
through :meth:`display_name`, not by exposing typed fields here.
emails: Raw JSContact EmailAddress map (:rfc:`9553#section-2.3.1`):
``{id: {"address": str, "contexts": {...}, "pref": int, ...}}``,
or ``None``.
"""
id: str
uid: str | None = None
address_book_ids: dict = field(default_factory=dict)
kind: str = "individual"
name: dict | None = None
emails: dict | None = None
[docs]
@classmethod
def from_jmap(cls, data: dict) -> JMAPContact:
"""Construct a JMAPContact from a raw JMAP ContactCard JSON dict.
Unknown keys (including JSContact properties this client doesn't
model, e.g. ``organizations``, ``addresses``, ``notes``) are
silently ignored for forward compatibility.
"""
return cls(
id=data["id"],
uid=data.get("uid"),
address_book_ids=data.get("addressBookIds", {}),
kind=data.get("kind", "individual"),
name=data.get("name"),
emails=data.get("emails"),
)
[docs]
def display_name(self) -> str | None:
"""Best-effort human-readable name.
:rfc:`9553#section-2.2.1.1`: a Name object has either ``full`` or
``components`` present, not necessarily both. Prefers ``full``;
falls back to ``components`` otherwise. When ``isOrdered`` is true,
a ``"separator"`` component's own ``value`` (or ``defaultSeparator``
if there's no explicit one at that position, or a single space if
neither is set) is inserted between the surrounding non-separator
components, per the RFC's own guidance, e.g. ``given="Jean"``,
``separator="-"``, ``surname="Paul"`` joins to ``"Jean-Paul"``, not
``"Jean Paul"``. When ``isOrdered`` is false, the RFC forbids any
``"separator"`` component at all, so components are simply
space-joined in list order. Returns ``None`` if neither ``full``
nor ``components`` is present.
"""
if not self.name:
return None
full = self.name.get("full")
if full:
return str(full)
components = self.name.get("components")
if not components:
return None
if not self.name.get("isOrdered"):
return " ".join(str(c["value"]) for c in components if c.get("kind") != "separator")
default_separator = self.name.get("defaultSeparator", " ")
parts: list[str] = []
pending_separator: str | None = None
for component in components:
if component.get("kind") == "separator":
pending_separator = str(component["value"])
continue
if parts:
parts.append(
pending_separator if pending_separator is not None else default_separator
)
parts.append(str(component["value"]))
pending_separator = None
return "".join(parts)
[docs]
def primary_email(self) -> str | None:
"""Return one email address for this contact, or ``None``.
Picks the entry with the lowest ``pref`` (:rfc:`9553#section-1.5.3`:
1 is most preferred, 100 least; an entry with no ``pref`` at all,
or an explicit ``null``, is treated as least preferred of all).
"""
if not self.emails:
return None
best = min(self.emails.values(), key=lambda e: e.get("pref") or _UNSET_PREF_SORT_KEY)
return best.get("address")