App passwords; CalDAV / CardDAV server for phones, Thunderbird, macOS

App passwords (SPEC App. D §D.5): one generated password per app, shown once,
revocable on its own, revoked on a password reset; never accepted by the web
sign-in. Settings → Security → App passwords (migration 0024).

CalDAV / CardDAV server (SPEC Appendix E), built in: /dav/ with
/.well-known/caldav|carddav, HTTP Basic with app passwords only (cached checks,
throttled failures). The calendar and contacts plugins serve their own tables as
collections (Plugin(dav=...)):
- calendars as iCalendar: series with overrides, time zones, all-day, attendees
  and RSVP, alarms <-> reminders; MKCALENDAR / PROPPATCH / DELETE of calendars;
  subscriptions read-only; writes to upstream-synced calendars flow on upstream;
- address books as vCard (3.0 served, 3.0/4.0 read), groups as CATEGORIES;
- what a client stores is served back verbatim until the web app edits it;
- sync tokens / CTags from change logs filled by triggers (calendar0008,
  contacts0002), so every write path counts; trimmed nightly after 90 days.
REPORTs: calendar-query (time range), calendar-/addressbook-multiget,
addressbook-query, sync-collection. nginx and the Vite proxy route /dav/.
Settings shows the server and user name to enter; install guide §5.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:49:04 +02:00
co-authored by Claude Opus 5.5
parent fa552392c8
commit 0e3793a4d6
30 changed files with 3968 additions and 4 deletions
@@ -13,7 +13,8 @@ from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, UiManifest
from webmail_plugin_contacts.dav import contacts_dav, prune_changes
from webmail_plugin_contacts.hook import on_message_sent
from webmail_plugin_contacts.router import router
@@ -25,6 +26,10 @@ plugin = Plugin(
builtin=True,
hooks={"message.sent": on_message_sent},
router=router,
# Address books served to CardDAV clients — phones, Thunderbird (SPEC App. E).
dav=contacts_dav,
# Trim the CardDAV change log nightly (sync tokens stay valid for 90 days).
jobs=[JobSpec(func=prune_changes, schedule="25 4 * * *")],
ui_manifest=UiManifest(
pages=[
Page(
@@ -0,0 +1,414 @@
"""The contacts plugin's CardDAV provider (SPEC App. E.4): address books as
CardDAV collections, each contact one vCard.
The same rows the web app reads and writes, in the user's RLS session. A
contact's groups are its CATEGORIES: a client's categories become (and are
kept in step with) the user's groups. Change numbers come from
``plg_contacts_dav_changes``, filled by triggers.
"""
from __future__ import annotations
import hashlib
import json
import uuid
from collections import defaultdict
from datetime import datetime
from typing import Any
from sqlalchemy import text
from app.dav.provider import Changes, Collection, DavError, Resource
from app.plugins.sdk import sdk
from webmail_plugin_contacts import vcard
_SDK = sdk("contacts")
_CR = "{urn:ietf:params:xml:ns:carddav}"
_COLUMNS = (
"c.id, c.book_id, c.vcard_uid, c.name_display, c.name_parts, c.nickname, c.emails, "
"c.default_email, c.phones, c.addresses, c.urls, c.org, c.department, c.title, "
"c.birthday, c.notes, c.starred, c.updated_at, c.srv_vcard, c.srv_vcard_ver, "
"plg_contacts_dav_name(c) AS dav_name"
)
class ContactsDav:
kind = "addressbook"
content_type = "text/vcard; charset=utf-8"
extension = ".vcf"
async def _book(self, s: Any, book_id: str) -> dict[str, Any]:
try:
uuid.UUID(book_id)
except ValueError:
raise DavError(404) from None
row = (
(
await s.execute(
text("SELECT id, kind, read_only FROM plg_contacts_books WHERE id = :id"),
{"id": book_id},
)
)
.mappings()
.first()
)
if row is None:
raise DavError(404)
return dict(row)
async def _rows(
self, s: Any, book_id: str, names: list[str] | None
) -> dict[str, dict[str, Any]]:
where = "c.book_id = :book"
params: dict[str, Any] = {"book": book_id}
if names is not None:
if not names:
return {}
where += " AND plg_contacts_dav_name(c) = ANY(:names)"
params["names"] = names
rows = (
(
await s.execute(
text(f"SELECT {_COLUMNS} FROM plg_contacts_contacts c WHERE {where}"), params
)
)
.mappings()
.all()
)
out = {r["dav_name"]: dict(r) for r in rows}
if out:
groups: dict[str, list[str]] = defaultdict(list)
for cid, name in (
await s.execute(
text(
"SELECT gm.contact_id, g.name FROM plg_contacts_group_members gm "
"JOIN plg_contacts_groups g ON g.id = gm.group_id "
"WHERE gm.contact_id = ANY(CAST(:ids AS uuid[])) ORDER BY g.name"
),
{"ids": [str(r["id"]) for r in out.values()]},
)
).all():
groups[str(cid)].append(name)
for r in out.values():
r["groups"] = groups.get(str(r["id"]), [])
return out
@staticmethod
def _serve(row: dict[str, Any], with_data: bool) -> tuple[str, str | None]:
fp = vcard.fingerprint(row, row.get("groups") or [])
raw = row.get("srv_vcard")
if raw and row.get("srv_vcard_ver") == fp:
return "r" + hashlib.sha1(raw.encode()).hexdigest(), raw # noqa: S324
return "g" + fp, vcard.build(row, row.get("groups") or []) if with_data else None
# -- collections -----------------------------------------------------------------
async def collections(self, user_id: uuid.UUID) -> list[Collection]:
from webmail_plugin_contacts.router import _ensure_book
async with _SDK.session(user_id) as s:
await _ensure_book(s, user_id) # a new user's first sync finds "Contacts"
rows = (
(
await s.execute(
text(
"SELECT b.id, b.name, b.kind, b.read_only, "
"COALESCE((SELECT max(id) FROM plg_contacts_dav_changes d "
" WHERE d.book_id = b.id), 0) AS token "
"FROM plg_contacts_books b ORDER BY b.name"
)
)
)
.mappings()
.all()
)
return [
Collection(
id=str(r["id"]),
displayname=r["name"],
sync_token=int(r["token"]),
read_only=bool(r["read_only"]) or r["kind"] != "local",
components=[],
)
for r in rows
]
async def create_collection(
self, user_id: uuid.UUID, collection_id: str, displayname: str, color: str | None
) -> None:
try:
bid = uuid.UUID(collection_id)
except ValueError:
raise DavError(403) from None
name = (displayname or "Address book").strip()[:100]
async with _SDK.session(user_id) as s:
taken = {
str(n).lower()
for n in (await s.execute(text("SELECT name FROM plg_contacts_books"))).scalars()
}
base, n = name, 2
while name.lower() in taken:
name, n = f"{base} ({n})", n + 1
await s.execute(
text("INSERT INTO plg_contacts_books (id, user_id, name) VALUES (:id, :u, :name)"),
{"id": str(bid), "u": str(user_id), "name": name},
)
async def update_collection(
self,
user_id: uuid.UUID,
collection_id: str,
*,
displayname: str | None = None,
color: str | None = None,
description: str | None = None,
) -> None:
if not displayname:
return
async with _SDK.session(user_id) as s:
await self._book(s, collection_id)
try:
await s.execute(
text("UPDATE plg_contacts_books SET name = :n WHERE id = :id"),
{"n": displayname.strip()[:100], "id": collection_id},
)
except Exception as err: # noqa: BLE001 - a duplicate name
raise DavError(409) from err
async def delete_collection(self, user_id: uuid.UUID, collection_id: str) -> None:
async with _SDK.session(user_id) as s:
book = await self._book(s, collection_id)
if book["kind"] != "local":
raise DavError(403)
count = await s.scalar(text("SELECT count(*) FROM plg_contacts_books"))
if (count or 0) <= 1:
raise DavError(403) # the last address book stays
await s.execute(
text("DELETE FROM plg_contacts_books WHERE id = :id"), {"id": collection_id}
)
# -- resources -------------------------------------------------------------------
async def resources(self, user_id: uuid.UUID, collection_id: str) -> list[Resource]:
return await self._get(user_id, collection_id, None, with_data=False)
async def get(
self, user_id: uuid.UUID, collection_id: str, names: list[str] | None
) -> list[Resource]:
return await self._get(user_id, collection_id, names, with_data=True)
async def _get(
self, user_id: uuid.UUID, collection_id: str, names: list[str] | None, *, with_data: bool
) -> list[Resource]:
async with _SDK.session(user_id) as s:
await self._book(s, collection_id)
rows = await self._rows(s, collection_id, names)
out = []
for name, row in rows.items():
etag, data = self._serve(row, with_data)
out.append(Resource(name=name, etag=etag, data=data))
return out
async def put(
self,
user_id: uuid.UUID,
collection_id: str,
name: str,
data: str,
*,
if_match: str | None,
if_none_match: bool,
) -> tuple[str, bool]:
try:
fields, groups = vcard.parse(data)
except vcard.InvalidVCard as err:
raise DavError(400, str(err), condition=f"{_CR}valid-address-data") from err
async with _SDK.session(user_id) as s:
book = await self._book(s, collection_id)
if book["read_only"] or book["kind"] != "local":
raise DavError(403)
existing = (await self._rows(s, collection_id, [name])).get(name)
if existing:
etag, _ = self._serve(existing, False)
if if_none_match or (if_match and if_match != "*" and if_match != etag):
raise DavError(412)
if existing["vcard_uid"] != fields["vcard_uid"]:
raise DavError(400, condition=f"{_CR}no-uid-conflict")
else:
if if_match:
raise DavError(412)
clash = await s.scalar(
text(
"SELECT plg_contacts_dav_name(c) FROM plg_contacts_contacts c "
"WHERE book_id = :b AND vcard_uid = :uid"
),
{"b": collection_id, "uid": fields["vcard_uid"]},
)
if clash and clash != name:
raise DavError(409, condition=f"{_CR}no-uid-conflict")
contact_id = await s.scalar(
text(
"INSERT INTO plg_contacts_contacts (user_id, book_id, vcard_uid, name_display, "
" name_parts, nickname, emails, default_email, phones, addresses, urls, org, "
" department, title, birthday, notes, starred, srv_name) "
"VALUES (:u, :b, :uid, :fn, CAST(:n AS jsonb), :nick, CAST(:emails AS jsonb), "
" :def, CAST(:phones AS jsonb), CAST(:adr AS jsonb), CAST(:urls AS jsonb), "
" :org, :dept, :title, :bday, :notes, :star, :name) "
"ON CONFLICT (book_id, vcard_uid) DO UPDATE SET "
" name_display = EXCLUDED.name_display, name_parts = EXCLUDED.name_parts, "
" nickname = EXCLUDED.nickname, emails = EXCLUDED.emails, "
" default_email = EXCLUDED.default_email, phones = EXCLUDED.phones, "
" addresses = EXCLUDED.addresses, urls = EXCLUDED.urls, org = EXCLUDED.org, "
" department = EXCLUDED.department, title = EXCLUDED.title, "
" birthday = EXCLUDED.birthday, notes = EXCLUDED.notes, "
" starred = EXCLUDED.starred, srv_name = EXCLUDED.srv_name "
"RETURNING id"
),
{
"u": str(user_id),
"b": collection_id,
"uid": fields["vcard_uid"],
"fn": fields["name_display"],
"n": json.dumps(fields["name_parts"]),
"nick": fields["nickname"],
"emails": json.dumps(fields["emails"]),
"def": fields["default_email"],
"phones": json.dumps(fields["phones"]),
"adr": json.dumps(fields["addresses"]),
"urls": json.dumps(fields["urls"]),
"org": fields["org"],
"dept": fields["department"],
"title": fields["title"],
"bday": fields["birthday"],
"notes": fields["notes"],
"star": fields["starred"],
"name": name,
},
)
await self._set_groups(s, user_id, str(contact_id), groups)
stored = (await self._rows(s, collection_id, [name]))[name]
fp = vcard.fingerprint(stored, stored.get("groups") or [])
await s.execute(
text(
"UPDATE plg_contacts_contacts SET srv_vcard = :raw, srv_vcard_ver = :fp "
"WHERE id = :id"
),
{"raw": data, "fp": fp, "id": str(contact_id)},
)
return "r" + hashlib.sha1(data.encode()).hexdigest(), not existing # noqa: S324
async def _set_groups(
self, s: Any, user_id: uuid.UUID, contact_id: str, names: list[str]
) -> None:
"""Make the contact's groups exactly ``names`` (creating missing ones)."""
ids: list[str] = []
for name in names:
gid = await s.scalar(
text(
"INSERT INTO plg_contacts_groups (user_id, name) VALUES (:u, :n) "
"ON CONFLICT (user_id, name) DO NOTHING RETURNING id"
),
{"u": str(user_id), "n": name[:100]},
)
if gid is None: # exists already (an update would mark all its members changed)
gid = await s.scalar(
text("SELECT id FROM plg_contacts_groups WHERE name = :n"), {"n": name[:100]}
)
ids.append(str(gid))
await s.execute(
text(
"DELETE FROM plg_contacts_group_members WHERE contact_id = :c "
"AND NOT (group_id = ANY(CAST(:ids AS uuid[])))"
),
{"c": contact_id, "ids": ids},
)
for gid in ids:
await s.execute(
text(
"INSERT INTO plg_contacts_group_members (user_id, group_id, contact_id) "
"VALUES (:u, :g, :c) ON CONFLICT DO NOTHING"
),
{"u": str(user_id), "g": gid, "c": contact_id},
)
async def delete(
self, user_id: uuid.UUID, collection_id: str, name: str, *, if_match: str | None
) -> None:
async with _SDK.session(user_id) as s:
book = await self._book(s, collection_id)
if book["read_only"] or book["kind"] != "local":
raise DavError(403)
existing = (await self._rows(s, collection_id, [name])).get(name)
if not existing:
raise DavError(404)
if if_match and if_match != "*" and if_match != self._serve(existing, False)[0]:
raise DavError(412)
await s.execute(
text("DELETE FROM plg_contacts_contacts WHERE id = :id"),
{"id": str(existing["id"])},
)
async def changes(self, user_id: uuid.UUID, collection_id: str, since: int) -> Changes | None:
async with _SDK.session(user_id) as s:
await self._book(s, collection_id)
rows = (
(
await s.execute(
text(
"SELECT id, name, deleted FROM plg_contacts_dav_changes "
"WHERE book_id = :b AND id > :since ORDER BY id"
),
{"b": collection_id, "since": since},
)
)
.mappings()
.all()
)
latest = await s.scalar(
text(
"SELECT COALESCE(max(id), 0) FROM plg_contacts_dav_changes WHERE book_id = :b"
),
{"b": collection_id},
)
oldest = await s.scalar(
text("SELECT min(id) FROM plg_contacts_dav_changes WHERE book_id = :b"),
{"b": collection_id},
)
if oldest is not None and since < int(oldest) - 1 and since < int(latest or 0):
return None # older entries were trimmed (prune_changes): resync
if since > int(latest or 0):
return None
state: dict[str, bool] = {}
for r in rows:
if r["name"]:
state[r["name"]] = bool(r["deleted"])
return Changes(
sync_token=int(latest or 0),
changed=[n for n, gone in state.items() if not gone],
deleted=[n for n, gone in state.items() if gone],
)
async def query(
self, user_id: uuid.UUID, collection_id: str, start: datetime | None, end: datetime | None
) -> list[str] | None:
return None # address books: the core returns them all
contacts_dav = ContactsDav()
async def prune_changes(ctx: dict[str, Any]) -> int:
"""Nightly: drop change-log entries older than 90 days, keeping each address
book's newest (its sync token). A client holding an older token is told to
resync (``changes`` answers None)."""
async with _SDK.public_session() as pub:
result = await pub.execute(
text(
"DELETE FROM plg_contacts_dav_changes d "
"WHERE created_at < now() - interval '90 days' "
"AND id < (SELECT max(id) FROM plg_contacts_dav_changes k "
" WHERE k.book_id = d.book_id)"
)
)
return int(result.rowcount or 0) # type: ignore[attr-defined]
@@ -0,0 +1,175 @@
"""contacts: serve address books to CardDAV clients (SPEC Appendix E.4)
* ``srv_name`` — the resource name a client stored the contact under (NULL:
``md5(uid).vcf``);
* ``srv_vcard`` / ``srv_vcard_ver`` — the vCard a client PUT, served back
verbatim while the contact's fields (and groups) still match;
* ``plg_contacts_dav_changes`` — numbered changes per address book (sync token /
CTag), filled by triggers: a contact changed, its groups changed (they are
its CATEGORIES), a group renamed, a book renamed.
Revision ID: contacts0002
Revises: contacts0001
Create Date: 2026-10-09
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.plugins.rls import enable_rls
revision: str = "contacts0002"
down_revision: str | None = "contacts0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
for col in ("srv_name", "srv_vcard", "srv_vcard_ver"):
op.execute(f"ALTER TABLE plg_contacts_contacts ADD COLUMN {col} text")
op.execute(
"""
CREATE TABLE plg_contacts_dav_changes (
id bigserial PRIMARY KEY,
user_id uuid NOT NULL,
book_id uuid NOT NULL,
name text, -- NULL: the address book itself changed
deleted boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
)
"""
)
op.execute(
"CREATE INDEX ix_plg_contacts_dav_changes_book ON plg_contacts_dav_changes (book_id, id)"
)
enable_rls("plg_contacts_dav_changes")
op.execute("GRANT USAGE ON SEQUENCE plg_contacts_dav_changes_id_seq TO webmail_plugin")
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_dav_name(c plg_contacts_contacts) RETURNS text AS $$
SELECT COALESCE(c.srv_name, md5(c.vcard_uid) || '.vcf')
$$ LANGUAGE sql IMMUTABLE;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_touch() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_contacts_touch
BEFORE UPDATE ON plg_contacts_contacts
FOR EACH ROW EXECUTE FUNCTION plg_contacts_touch();
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_log_change() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND NEW.book_id <> OLD.book_id) THEN
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
VALUES (OLD.user_id, OLD.book_id, plg_contacts_dav_name(OLD), true);
END IF;
IF TG_OP <> 'DELETE' THEN
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
VALUES (NEW.user_id, NEW.book_id, plg_contacts_dav_name(NEW), false);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_contacts_log
AFTER INSERT OR UPDATE OR DELETE ON plg_contacts_contacts
FOR EACH ROW EXECUTE FUNCTION plg_contacts_log_change();
"""
)
# Group membership is part of the vCard (CATEGORIES).
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_log_member() RETURNS trigger AS $$
DECLARE
m plg_contacts_group_members;
BEGIN
m := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
SELECT c.user_id, c.book_id, plg_contacts_dav_name(c), false
FROM plg_contacts_contacts c WHERE c.id = m.contact_id;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_group_members_log
AFTER INSERT OR DELETE ON plg_contacts_group_members
FOR EACH ROW EXECUTE FUNCTION plg_contacts_log_member();
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_log_group() RETURNS trigger AS $$
BEGIN
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
SELECT c.user_id, c.book_id, plg_contacts_dav_name(c), false
FROM plg_contacts_group_members gm
JOIN plg_contacts_contacts c ON c.id = gm.contact_id
WHERE gm.group_id = NEW.id;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_groups_log
AFTER UPDATE OF name ON plg_contacts_groups
FOR EACH ROW EXECUTE FUNCTION plg_contacts_log_group();
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_log_book() RETURNS trigger AS $$
BEGIN
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
VALUES (NEW.user_id, NEW.id, NULL, false);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_books_log
AFTER INSERT OR UPDATE OF name ON plg_contacts_books
FOR EACH ROW EXECUTE FUNCTION plg_contacts_log_book();
"""
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS plg_contacts_books_log ON plg_contacts_books")
op.execute("DROP TRIGGER IF EXISTS plg_contacts_groups_log ON plg_contacts_groups")
op.execute(
"DROP TRIGGER IF EXISTS plg_contacts_group_members_log ON plg_contacts_group_members"
)
op.execute("DROP TRIGGER IF EXISTS plg_contacts_contacts_log ON plg_contacts_contacts")
op.execute("DROP TRIGGER IF EXISTS plg_contacts_contacts_touch ON plg_contacts_contacts")
for fn in ("log_book", "log_group", "log_member", "log_change", "touch"):
op.execute(f"DROP FUNCTION IF EXISTS plg_contacts_{fn}()")
op.execute("DROP FUNCTION IF EXISTS plg_contacts_dav_name(plg_contacts_contacts)")
op.execute("DROP TABLE IF EXISTS plg_contacts_dav_changes")
for col in ("srv_vcard_ver", "srv_vcard", "srv_name"):
op.execute(f"ALTER TABLE plg_contacts_contacts DROP COLUMN IF EXISTS {col}")
@@ -0,0 +1,338 @@
"""vCard (RFC 2426 v3.0, RFC 6350 v4.0) <-> contact fields (SPEC App. E.4).
:func:`parse` reads a vCard 3.0 or 4.0 a client sent into the columns of
``plg_contacts_contacts`` (plus its CATEGORIES, which are the user's groups);
:func:`build` writes a contact as vCard 3.0, which every client reads.
:func:`fingerprint` summarises the modelled fields, so a client's own text can
be served back verbatim for as long as they are unchanged.
"""
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from datetime import date, datetime
from typing import Any
FIELDS = (
"vcard_uid",
"name_display",
"name_parts",
"nickname",
"emails",
"default_email",
"phones",
"addresses",
"urls",
"org",
"department",
"title",
"birthday",
"notes",
"starred",
)
class InvalidVCard(ValueError):
pass
def fingerprint(row: Mapping[str, Any], groups: Sequence[str]) -> str:
data = {f: row.get(f) for f in FIELDS}
data["groups"] = sorted(groups, key=str.lower)
return hashlib.sha1(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
# ---- text level -----------------------------------------------------------------------
def _unfold(text: str) -> list[str]:
lines: list[str] = []
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
if raw[:1] in (" ", "\t") and lines:
lines[-1] += raw[1:]
elif raw.strip():
lines.append(raw)
return lines
def _split_unescaped(value: str, sep: str) -> list[str]:
parts, cur, esc = [], [], False
for ch in value:
if esc:
cur.append("\\" + ch)
esc = False
elif ch == "\\":
esc = True
elif ch == sep:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
parts.append("".join(cur))
return parts
def _unescape(value: str) -> str:
return re.sub(r"\\(.)", lambda m: "\n" if m.group(1) in "nN" else m.group(1), value)
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
class _Line:
def __init__(self, raw: str) -> None:
head, sep, value = self._split(raw)
if not sep:
raise InvalidVCard(f"bad line: {raw[:40]}")
bits = _split_params(head)
name = bits[0]
self.name = name.rsplit(".", 1)[-1].upper() # "item1.EMAIL" -> EMAIL
self.params: dict[str, list[str]] = {}
for p in bits[1:]:
key, eq, val = p.partition("=")
if not eq: # vCard 2.1-style bare type ("WORK")
key, val = "TYPE", key
vals = [v.strip('"') for v in val.split(",")]
self.params.setdefault(key.upper(), []).extend(v for v in vals if v)
self.value = value
@staticmethod
def _split(raw: str) -> tuple[str, str, str]:
quoted = False
for i, ch in enumerate(raw):
if ch == '"':
quoted = not quoted
elif ch == ":" and not quoted:
return raw[:i], ":", raw[i + 1 :]
return raw, "", ""
def types(self) -> set[str]:
return {t.lower() for t in self.params.get("TYPE", [])}
def pref(self) -> bool:
return "pref" in self.types() or bool(self.params.get("PREF"))
def _split_params(head: str) -> list[str]:
out, cur, quoted = [], [], False
for ch in head:
if ch == '"':
quoted = not quoted
cur.append(ch)
elif ch == ";" and not quoted:
out.append("".join(cur))
cur = []
else:
cur.append(ch)
out.append("".join(cur))
return out
# ---- vCard -> fields ------------------------------------------------------------------
def _birthday(value: str) -> date | None:
v = value.strip()
for fmt in ("%Y-%m-%d", "%Y%m%d"):
try:
return datetime.strptime(v[:10] if "-" in v else v[:8], fmt).date()
except ValueError:
continue
return None # "--MMDD" (no year) and the like are not kept as a date
def parse(text: str) -> tuple[dict[str, Any], list[str]]:
"""(contact fields, group names) of one vCard. Raises InvalidVCard."""
lines = _unfold(text)
if not lines or lines[0].strip().upper() != "BEGIN:VCARD":
raise InvalidVCard("not a vCard")
if sum(1 for line in lines if line.strip().upper() == "BEGIN:VCARD") != 1:
raise InvalidVCard("one vCard per resource")
row: dict[str, Any] = {
"vcard_uid": "",
"name_display": "",
"name_parts": {"prefix": "", "given": "", "middle": "", "family": "", "suffix": ""},
"nickname": "",
"emails": [],
"default_email": "",
"phones": [],
"addresses": [],
"urls": [],
"org": "",
"department": "",
"title": "",
"birthday": None,
"notes": "",
"starred": False,
}
groups: list[str] = []
for raw in lines[1:]:
if raw.strip().upper() == "END:VCARD":
break
line = _Line(raw)
name, value, types = line.name, line.value, line.types()
if name == "UID":
v = _unescape(value).strip()
row["vcard_uid"] = v[9:] if v.lower().startswith("urn:uuid:") else v
elif name == "FN":
row["name_display"] = _unescape(value).strip()
elif name == "N":
parts = [_unescape(p).strip() for p in _split_unescaped(value, ";")] + [""] * 5
row["name_parts"] = {
"family": parts[0],
"given": parts[1],
"middle": parts[2],
"prefix": parts[3],
"suffix": parts[4],
}
elif name == "NICKNAME":
row["nickname"] = _unescape(_split_unescaped(value, ",")[0]).strip()
elif name == "EMAIL":
addr = _unescape(value).strip()
if addr.lower().startswith("mailto:"):
addr = addr[7:]
if addr:
kind = "work" if "work" in types else "personal" if "home" in types else "other"
row["emails"].append({"value": addr, "type": kind, "pref": line.pref()})
elif name == "TEL":
number = _unescape(value).strip()
if number.lower().startswith("tel:"):
number = number[4:]
if number:
kind = (
"mobile"
if "cell" in types
else "work"
if "work" in types
else "home"
if "home" in types
else "other"
)
row["phones"].append({"value": number, "type": kind})
elif name == "ADR":
p = [_unescape(x).strip() for x in _split_unescaped(value, ";")] + [""] * 7
street = ", ".join(x for x in (p[1], p[2]) if x) # extended + street
kind = "work" if "work" in types else "home" if "home" in types else "other"
if any((street, p[3], p[5], p[6])):
row["addresses"].append(
{
"type": kind,
"street": street,
"city": p[3],
"postal_code": p[5],
"country": p[6],
}
)
elif name == "URL":
url = _unescape(value).strip()
if url:
row["urls"].append({"value": url, "type": "website"})
elif name == "ORG":
p = [_unescape(x).strip() for x in _split_unescaped(value, ";")] + [""]
row["org"], row["department"] = p[0], p[1]
elif name == "TITLE":
row["title"] = _unescape(value).strip()
elif name == "BDAY":
row["birthday"] = _birthday(_unescape(value))
elif name == "NOTE":
row["notes"] = _unescape(value)
elif name == "CATEGORIES":
groups.extend(
g for g in (_unescape(x).strip() for x in _split_unescaped(value, ",")) if g
)
elif name == "X-ULTIMO-VIP":
row["starred"] = value.strip() in ("1", "TRUE", "true")
if not row["vcard_uid"]:
raise InvalidVCard("UID is required")
if not row["name_display"]:
n = row["name_parts"]
row["name_display"] = (
" ".join(
x for x in (n["prefix"], n["given"], n["middle"], n["family"], n["suffix"]) if x
)
or row["org"]
or (row["emails"][0]["value"] if row["emails"] else "")
)
pref = next((e["value"] for e in row["emails"] if e["pref"]), None)
row["default_email"] = pref or (row["emails"][0]["value"] if row["emails"] else "")
return row, list(dict.fromkeys(groups))
# ---- fields -> vCard ------------------------------------------------------------------
def _fold(line: str) -> str:
"""Fold at 75 octets (RFC 6350 §3.2), never inside a UTF-8 character."""
out: list[str] = []
cur = b""
for ch in line:
b = ch.encode()
if len(cur) + len(b) > (75 if not out else 74):
out.append(cur.decode())
cur = b""
cur += b
out.append(cur.decode())
return "\r\n ".join(out)
def build(row: Mapping[str, Any], groups: Sequence[str]) -> str:
"""A contact as vCard 3.0."""
lines = ["BEGIN:VCARD", "VERSION:3.0", "PRODID:-//Ultimo//Contacts//EN"]
lines.append(f"UID:{_escape(str(row['vcard_uid']))}")
lines.append(f"FN:{_escape(row.get('name_display') or '')}")
n = row.get("name_parts") or {}
lines.append(
"N:"
+ ";".join(
_escape(str(n.get(k, "") or ""))
for k in ("family", "given", "middle", "prefix", "suffix")
)
)
if row.get("nickname"):
lines.append(f"NICKNAME:{_escape(row['nickname'])}")
if row.get("org") or row.get("department"):
lines.append(f"ORG:{_escape(row.get('org') or '')};{_escape(row.get('department') or '')}")
if row.get("title"):
lines.append(f"TITLE:{_escape(row['title'])}")
default = (row.get("default_email") or "").lower()
for e in row.get("emails") or []:
kind = {"work": "WORK", "personal": "HOME"}.get(e.get("type", ""), "OTHER")
pref = (
",pref" if e.get("pref") or (default and e.get("value", "").lower() == default) else ""
)
lines.append(f"EMAIL;TYPE=INTERNET,{kind}{pref}:{_escape(e.get('value', ''))}")
for p in row.get("phones") or []:
kind = {"mobile": "CELL", "home": "HOME", "work": "WORK"}.get(p.get("type", ""), "VOICE")
lines.append(f"TEL;TYPE={kind}:{_escape(p.get('value', ''))}")
for a in row.get("addresses") or []:
kind = {"home": "HOME", "work": "WORK"}.get(a.get("type", ""), "OTHER")
parts = [
"",
"",
a.get("street", ""),
a.get("city", ""),
"",
a.get("postal_code", ""),
a.get("country", ""),
]
lines.append(f"ADR;TYPE={kind}:" + ";".join(_escape(str(x or "")) for x in parts))
for u in row.get("urls") or []:
lines.append(f"URL:{_escape(u.get('value', ''))}")
bday = row.get("birthday")
if bday:
lines.append(f"BDAY:{bday.isoformat() if hasattr(bday, 'isoformat') else bday}")
if row.get("notes"):
lines.append(f"NOTE:{_escape(row['notes'])}")
if groups:
lines.append("CATEGORIES:" + ",".join(_escape(g) for g in groups))
if row.get("starred"):
lines.append("X-ULTIMO-VIP:1")
updated = row.get("updated_at")
if isinstance(updated, datetime):
lines.append(f"REV:{updated.strftime('%Y%m%dT%H%M%SZ')}")
lines.append("END:VCARD")
return "\r\n".join(_fold(line) for line in lines) + "\r\n"