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
+118
View File
@@ -0,0 +1,118 @@
"""What a plugin implements to serve its collections over DAV (SPEC App. E.1).
The core (app/dav/routes.py) speaks the protocol; a provider only knows its
own data: collections (calendars, address books) and the resources in them
(one iCalendar object / one vCard each), named, with an ETag. Every method
gets the signed-in user's id; providers open the user's RLS session
(``sdk(name).session(user_id)``) themselves.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol
class DavError(Exception):
"""A request the provider refuses: ``status`` is the HTTP answer
(403 read-only, 404 missing, 409 conflict, 412 precondition failed, 415
unsupported data); ``condition`` an optional DAV precondition element
(e.g. ``{urn:ietf:params:xml:ns:caldav}valid-calendar-data``)."""
def __init__(self, status: int, message: str = "", condition: str | None = None) -> None:
super().__init__(message or str(status))
self.status = status
self.condition = condition
@dataclass
class Collection:
id: str
displayname: str
sync_token: int # the latest change number; also the CTag
description: str = ""
color: str | None = None # "#rrggbb"
read_only: bool = False
components: list[str] = field(default_factory=lambda: ["VEVENT"]) # calendars only
@dataclass
class Resource:
name: str # last path segment, e.g. "abc.ics"
etag: str # without quotes
data: str | None = None # the iCalendar / vCard text, when asked for
@dataclass
class Changes:
sync_token: int
changed: list[str] # resource names changed or added since the old token
deleted: list[str] # resource names gone since the old token
class DavProvider(Protocol):
kind: Literal["calendar", "addressbook"]
content_type: str # "text/calendar; charset=utf-8" / "text/vcard; charset=utf-8"
extension: str # ".ics" / ".vcf"
async def collections(self, user_id: uuid.UUID) -> list[Collection]: ...
async def resources(self, user_id: uuid.UUID, collection_id: str) -> list[Resource]: ...
async def get(
self, user_id: uuid.UUID, collection_id: str, names: list[str] | None
) -> list[Resource]: ...
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]:
"""Store ``data`` as ``name``; returns (new ETag, created)."""
...
async def delete(
self, user_id: uuid.UUID, collection_id: str, name: str, *, if_match: str | None
) -> None: ...
async def changes(self, user_id: uuid.UUID, collection_id: str, since: int) -> Changes | None:
"""What changed after change number ``since``; None when that token is
too old to answer (the client then resyncs from scratch)."""
...
async def create_collection(
self, user_id: uuid.UUID, collection_id: str, displayname: str, color: str | None
) -> None:
"""Create a collection at the id the client chose (MKCALENDAR / MKCOL);
DavError(403) when that id cannot be used."""
...
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: ...
async def delete_collection(self, user_id: uuid.UUID, collection_id: str) -> None: ...
async def query(
self,
user_id: uuid.UUID,
collection_id: str,
start: datetime | None,
end: datetime | None,
) -> list[str] | None:
"""Calendars: names of resources with an occurrence overlapping the
range; None when the provider cannot filter (the core then returns all)."""
...