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:
@@ -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]
|
||||
+175
@@ -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"
|
||||
Reference in New Issue
Block a user