diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
index c27cc72..d36002c 100644
--- a/backend/app/api/__init__.py
+++ b/backend/app/api/__init__.py
@@ -49,6 +49,10 @@ def register_routers(app: FastAPI) -> None:
):
app.include_router(router, prefix="/api")
app.include_router(ws_router, prefix="/api")
+ # CalDAV / CardDAV (SPEC Appendix E): at the root, where clients look for it.
+ from app.dav.routes import router as dav_router
+
+ app.include_router(dav_router)
# Mount discovered plugin routers at /api/plugins/{name} (SPEC §11). Hooks are
# subscribed in the lifespan startup so they register once per process.
diff --git a/backend/app/dav/__init__.py b/backend/app/dav/__init__.py
new file mode 100644
index 0000000..69f2b41
--- /dev/null
+++ b/backend/app/dav/__init__.py
@@ -0,0 +1 @@
+"""CalDAV / CardDAV server (SPEC Appendix E)."""
diff --git a/backend/app/dav/auth.py b/backend/app/dav/auth.py
new file mode 100644
index 0000000..97b0a3c
--- /dev/null
+++ b/backend/app/dav/auth.py
@@ -0,0 +1,93 @@
+"""Signing in a DAV client: HTTP Basic with an app password (SPEC App. E.2).
+
+Only app passwords: the account password (with or without 2FA) never works
+here, so a lost phone is one revocation away and 2FA cannot be bypassed. A
+sync makes many requests, each with the credentials; a successful check is
+remembered for a few minutes under a hash of the password, and forgotten as
+soon as the user's app passwords change (``app_passwords`` bumps a counter).
+"""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import datetime as dt
+import hashlib
+import uuid
+
+from sqlalchemy import select, update
+
+from app.db import session_scope
+from app.identity import app_passwords
+from app.identity.service import resolve_handle
+from app.models import User, UserAppPassword
+from app.redis_client import get_redis
+
+CACHE_S = 300
+FAIL_WINDOW_S = 900
+MAX_FAILS_PER_ADDRESS = 10
+MAX_FAILS_PER_IP = 30
+_LAST_USED_EVERY = dt.timedelta(minutes=5)
+
+
+class Throttled(Exception):
+ """Too many failed sign-ins recently."""
+
+
+def _credentials(header: str | None) -> tuple[str, str] | None:
+ if not header or not header.lower().startswith("basic "):
+ return None
+ try:
+ decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8")
+ except (binascii.Error, UnicodeDecodeError):
+ return None
+ login, sep, password = decoded.partition(":")
+ return (login.strip(), password) if sep and login.strip() else None
+
+
+async def authenticate(header: str | None, ip: str) -> User | None:
+ """The user these Basic credentials sign in, or None. Raises Throttled."""
+ creds = _credentials(header)
+ if creds is None:
+ return None
+ login, password = creds
+ redis = get_redis()
+ handle = resolve_handle(login)
+ fail_keys = (f"dav:fail:addr:{handle}", f"dav:fail:ip:{ip}")
+ fails = [int(await redis.get(k) or 0) for k in fail_keys]
+ if fails[0] >= MAX_FAILS_PER_ADDRESS or fails[1] >= MAX_FAILS_PER_IP:
+ raise Throttled
+
+ async with session_scope() as db:
+ user = await db.scalar(select(User).where(User.handle == handle))
+ if user is None or user.blocked_at is not None:
+ await _failed(fail_keys)
+ return None
+ gen = int(await redis.get(app_passwords.generation_key(user.id)) or 0)
+ digest = hashlib.sha256(f"{user.id}:{password}".encode()).hexdigest()
+ cache_key = f"dav:auth:{user.id}:{gen}:{digest}"
+ if await redis.get(cache_key):
+ return user
+ row = await app_passwords.verify(db, user.id, password)
+ if row is None:
+ await _failed(fail_keys)
+ return None
+ await redis.set(cache_key, str(row.id), ex=CACHE_S)
+ now = dt.datetime.now(dt.UTC)
+ if row.last_used_at is None or now - row.last_used_at > _LAST_USED_EVERY:
+ await db.execute(
+ update(UserAppPassword).where(UserAppPassword.id == row.id).values(last_used_at=now)
+ )
+ db.expunge(user)
+ return user
+
+
+async def _failed(keys: tuple[str, ...]) -> None:
+ redis = get_redis()
+ for key in keys:
+ await redis.incr(key)
+ await redis.expire(key, FAIL_WINDOW_S)
+
+
+def user_id_of(user: User) -> uuid.UUID:
+ return user.id
diff --git a/backend/app/dav/provider.py b/backend/app/dav/provider.py
new file mode 100644
index 0000000..418795e
--- /dev/null
+++ b/backend/app/dav/provider.py
@@ -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)."""
+ ...
diff --git a/backend/app/dav/routes.py b/backend/app/dav/routes.py
new file mode 100644
index 0000000..c5add3b
--- /dev/null
+++ b/backend/app/dav/routes.py
@@ -0,0 +1,595 @@
+"""The CalDAV / CardDAV endpoints (SPEC Appendix E).
+
+ /.well-known/caldav, /.well-known/carddav → 301 /dav/
+ /dav/ root: points at the principal
+ /dav/principals/{user}/ the signed-in user
+ /dav/calendars/{user}/[{calendar}/[{name}]] calendar home, calendars, events
+ /dav/addressbooks/{user}/[{book}/[{name}]] address-book home, books, contacts
+
+The data comes from the plugins' providers (app/dav/provider.py): the calendar
+plugin's for ``calendars``, the contacts plugin's for ``addressbooks``. Signed
+in with HTTP Basic and an app password only (app/dav/auth.py); session cookies
+are never looked at, so a web page cannot drive these endpoints.
+"""
+
+from __future__ import annotations
+
+import logging
+import uuid
+import xml.etree.ElementTree as ET
+from typing import Any
+from urllib.parse import quote, unquote, urlsplit
+
+from fastapi import APIRouter, Request, Response
+from fastapi.responses import RedirectResponse
+from sqlalchemy import select
+
+from app.dav import auth
+from app.dav.provider import Collection, DavError, DavProvider, Resource
+from app.dav.xml import (
+ CR,
+ CS,
+ A,
+ BadRequest,
+ C,
+ D,
+ Multistatus,
+ PropValue,
+ el,
+ error_body,
+ href,
+ mkcol_props,
+ parse,
+ propfind_request,
+ proppatch_request,
+ q,
+ report_request,
+)
+from app.db import session_scope
+from app.models import Account, User
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+METHODS = [
+ "OPTIONS",
+ "PROPFIND",
+ "PROPPATCH",
+ "REPORT",
+ "GET",
+ "HEAD",
+ "PUT",
+ "DELETE",
+ "MKCALENDAR",
+ "MKCOL",
+]
+DAV_HEADER = "1, 3, calendar-access, addressbook, extended-mkcol"
+XML_TYPE = "application/xml; charset=utf-8"
+HOMES = {"calendars": "calendar", "addressbooks": "addressbook"}
+SYNC_PREFIX = "http://ultimo.email/ns/sync/"
+
+
+def _providers() -> dict[str, DavProvider]:
+ from app.plugins import loader
+
+ out: dict[str, DavProvider] = {}
+ for plugin in loader.get_plugins():
+ if plugin.dav is not None:
+ out[plugin.dav.kind] = plugin.dav
+ return out
+
+
+# ---- entry points ------------------------------------------------------------------
+
+
+@router.api_route("/.well-known/caldav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
+@router.api_route("/.well-known/carddav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
+async def well_known() -> Response:
+ return RedirectResponse("/dav/", status_code=301)
+
+
+@router.api_route("/dav", methods=METHODS)
+@router.api_route("/dav/{path:path}", methods=METHODS)
+async def dav(request: Request, path: str = "") -> Response:
+ method = request.method.upper()
+ if method == "OPTIONS":
+ return Response(
+ status_code=200,
+ headers={"DAV": DAV_HEADER, "Allow": ", ".join(METHODS), "Content-Length": "0"},
+ )
+ ip = request.client.host if request.client else ""
+ try:
+ user = await auth.authenticate(request.headers.get("authorization"), ip)
+ except auth.Throttled:
+ return Response(status_code=429, headers={"Retry-After": "900"})
+ if user is None:
+ return Response(
+ status_code=401,
+ headers={"WWW-Authenticate": 'Basic realm="Ultimo", charset="UTF-8"'},
+ )
+ parts = [unquote(p) for p in path.split("/") if p]
+ ctx = _Ctx(request, user, parts)
+ try:
+ return await ctx.handle(method)
+ except BadRequest as err:
+ return Response(status_code=400, content=str(err))
+ except DavError as err:
+ body = error_body(err.condition) if err.condition else b""
+ return Response(
+ status_code=err.status,
+ content=body,
+ media_type=XML_TYPE if body else None,
+ )
+
+
+class _Ctx:
+ def __init__(self, request: Request, user: User, parts: list[str]) -> None:
+ self.request = request
+ self.user = user
+ self.uid = str(user.id)
+ self.parts = parts
+ self.providers = _providers()
+
+ # -- paths -----------------------------------------------------------------------
+
+ def principal(self) -> str:
+ return f"/dav/principals/{self.uid}/"
+
+ def home(self, segment: str) -> str:
+ return f"/dav/{segment}/{self.uid}/"
+
+ def coll_href(self, segment: str, coll_id: str) -> str:
+ return f"{self.home(segment)}{quote(coll_id, safe='')}/"
+
+ def res_href(self, segment: str, coll_id: str, name: str) -> str:
+ return f"{self.coll_href(segment, coll_id)}{quote(name, safe='')}"
+
+ # -- dispatch --------------------------------------------------------------------
+
+ async def handle(self, method: str) -> Response:
+ p = self.parts
+ if not p:
+ return await self.root(method)
+ if p[0] == "principals":
+ if len(p) != 2 or p[1] != self.uid:
+ raise DavError(404)
+ return await self.principal_req(method)
+ if p[0] in HOMES and len(p) >= 2:
+ if p[1] != self.uid:
+ raise DavError(403)
+ provider = self.providers.get(HOMES[p[0]])
+ if provider is None:
+ raise DavError(404)
+ if len(p) == 2:
+ return await self.home_req(method, p[0], provider)
+ if len(p) == 3:
+ return await self.collection_req(method, p[0], provider, p[2])
+ if len(p) == 4:
+ return await self.resource_req(method, p[0], provider, p[2], p[3])
+ raise DavError(404)
+
+ async def body(self) -> ET.Element | None:
+ return parse(await self.request.body())
+
+ def depth(self) -> int:
+ value = (self.request.headers.get("depth") or "1").strip().lower()
+ return 0 if value == "0" else 1
+
+ @staticmethod
+ def multistatus(ms: Multistatus) -> Response:
+ return Response(status_code=207, content=ms.to_bytes(), media_type=XML_TYPE)
+
+ async def respond_props(
+ self, items: list[tuple[str, dict[str, PropValue]]], method_root: ET.Element | None
+ ) -> Response:
+ req = propfind_request(method_root)
+ ms = Multistatus()
+ for path, props in items:
+ if req.names_only:
+ ms.response(path, dict.fromkeys(props))
+ elif req.names is None:
+ ms.response(path, props)
+ else:
+ found = {n: props[n] for n in req.names if n in props}
+ missing = [n for n in req.names if n not in props]
+ ms.response(path, found, missing)
+ return self.multistatus(ms)
+
+ # -- root and principal ----------------------------------------------------------
+
+ def common(self) -> dict[str, PropValue]:
+ return {
+ q(D, "current-user-principal"): [href(self.principal())],
+ q(D, "principal-URL"): [href(self.principal())],
+ q(D, "owner"): [href(self.principal())],
+ }
+
+ async def root(self, method: str) -> Response:
+ if method != "PROPFIND":
+ return Response(status_code=200 if method in ("GET", "HEAD") else 405)
+ props = {**self.common(), q(D, "resourcetype"): [el(q(D, "collection"))]}
+ return await self.respond_props([("/dav/", props)], await self.body())
+
+ async def addresses(self) -> list[str]:
+ async with session_scope() as db:
+ emails = list(
+ await db.scalars(select(Account.email).where(Account.user_id == self.user.id))
+ )
+ out = [self.user.handle, *emails]
+ return list(dict.fromkeys(e.lower() for e in out))
+
+ async def principal_props(self) -> dict[str, PropValue]:
+ props = {
+ **self.common(),
+ q(D, "resourcetype"): [el(q(D, "principal")), el(q(D, "collection"))],
+ q(D, "displayname"): self.user.handle,
+ q(C, "calendar-user-address-set"): [
+ href(f"mailto:{a}") for a in await self.addresses()
+ ],
+ q(C, "calendar-user-type"): "INDIVIDUAL",
+ q(D, "principal-collection-set"): [href("/dav/principals/")],
+ q(D, "supported-report-set"): [],
+ }
+ if "calendar" in self.providers:
+ props[q(C, "calendar-home-set")] = [href(self.home("calendars"))]
+ if "addressbook" in self.providers:
+ props[q(CR, "addressbook-home-set")] = [href(self.home("addressbooks"))]
+ return props
+
+ async def principal_req(self, method: str) -> Response:
+ if method == "PROPFIND":
+ return await self.respond_props(
+ [(self.principal(), await self.principal_props())], await self.body()
+ )
+ if method == "REPORT": # principal-property-search, expand-property: nothing to add
+ return self.multistatus(Multistatus())
+ return Response(status_code=405)
+
+ # -- homes -------------------------------------------------------------------------
+
+ def privileges(self, read_only: bool) -> list[ET.Element]:
+ names = ["read", "read-current-user-privilege-set"]
+ if not read_only:
+ names += ["write", "write-content", "write-properties", "bind", "unbind"]
+ return [el(q(D, "privilege"), children=[el(q(D, n))]) for n in names]
+
+ def reports(self, kind: str) -> list[ET.Element]:
+ ns, reports = (
+ (C, ["calendar-query", "calendar-multiget"])
+ if kind == "calendar"
+ else (CR, ["addressbook-query", "addressbook-multiget"])
+ )
+ items = [(ns, r) for r in reports] + [(D, "sync-collection")]
+ return [
+ el(q(D, "supported-report"), children=[el(q(D, "report"), children=[el(q(n, r))])])
+ for n, r in items
+ ]
+
+ async def home_req(self, method: str, segment: str, provider: DavProvider) -> Response:
+ if method == "PROPFIND":
+ home_props: dict[str, PropValue] = {
+ **self.common(),
+ q(D, "resourcetype"): [el(q(D, "collection"))],
+ q(D, "displayname"): "Calendars" if provider.kind == "calendar" else "Contacts",
+ q(D, "current-user-privilege-set"): self.privileges(False),
+ }
+ items = [(self.home(segment), home_props)]
+ if self.depth():
+ for coll in await provider.collections(self.user.id):
+ items.append(
+ (self.coll_href(segment, coll.id), self.collection_props(provider, coll))
+ )
+ return await self.respond_props(items, await self.body())
+ if method == "REPORT":
+ return self.multistatus(Multistatus())
+ if method in ("GET", "HEAD"):
+ return Response(status_code=200)
+ return Response(status_code=405)
+
+ # -- collections -------------------------------------------------------------------
+
+ def collection_props(self, provider: DavProvider, coll: Collection) -> dict[str, PropValue]:
+ token = f"{SYNC_PREFIX}{coll.sync_token}"
+ props: dict[str, PropValue] = {
+ **self.common(),
+ q(D, "displayname"): coll.displayname,
+ q(D, "current-user-privilege-set"): self.privileges(coll.read_only),
+ q(D, "supported-report-set"): self.reports(provider.kind),
+ q(D, "sync-token"): token,
+ q(CS, "getctag"): str(coll.sync_token),
+ }
+ if provider.kind == "calendar":
+ props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(C, "calendar"))]
+ props[q(C, "supported-calendar-component-set")] = [
+ el(q(C, "comp"), attrs={"name": comp}) for comp in coll.components
+ ]
+ props[q(C, "supported-calendar-data")] = [
+ el(q(C, "calendar-data"), attrs={"content-type": "text/calendar", "version": "2.0"})
+ ]
+ props[q(C, "calendar-description")] = coll.description
+ if coll.color:
+ props[q(A, "calendar-color")] = coll.color
+ else:
+ props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(CR, "addressbook"))]
+ props[q(CR, "supported-address-data")] = [
+ el(
+ q(CR, "address-data-type"),
+ attrs={"content-type": "text/vcard", "version": "3.0"},
+ ),
+ el(
+ q(CR, "address-data-type"),
+ attrs={"content-type": "text/vcard", "version": "4.0"},
+ ),
+ ]
+ props[q(CR, "addressbook-description")] = coll.description
+ return props
+
+ async def find(self, provider: DavProvider, coll_id: str) -> Collection:
+ for coll in await provider.collections(self.user.id):
+ if coll.id == coll_id:
+ return coll
+ raise DavError(404)
+
+ def resource_props(
+ self, provider: DavProvider, res: Resource, with_data: bool
+ ) -> dict[str, PropValue]:
+ props: dict[str, PropValue] = {
+ q(D, "getetag"): f'"{res.etag}"',
+ q(D, "getcontenttype"): provider.content_type,
+ q(D, "resourcetype"): [],
+ }
+ if with_data and res.data is not None:
+ data_prop = (
+ q(C, "calendar-data") if provider.kind == "calendar" else q(CR, "address-data")
+ )
+ props[data_prop] = res.data
+ return props
+
+ async def collection_req(
+ self, method: str, segment: str, provider: DavProvider, coll_id: str
+ ) -> Response:
+ if method == "MKCALENDAR" or method == "MKCOL":
+ return await self.make_collection(segment, provider, coll_id)
+ coll = await self.find(provider, coll_id)
+ if method == "PROPFIND":
+ items = [(self.coll_href(segment, coll.id), self.collection_props(provider, coll))]
+ if self.depth():
+ for res in await provider.resources(self.user.id, coll.id):
+ items.append(
+ (
+ self.res_href(segment, coll.id, res.name),
+ self.resource_props(provider, res, False),
+ )
+ )
+ return await self.respond_props(items, await self.body())
+ if method == "REPORT":
+ return await self.report(segment, provider, coll)
+ if method == "PROPPATCH":
+ return await self.proppatch(segment, provider, coll)
+ if method == "DELETE":
+ if coll.read_only:
+ raise DavError(403)
+ await provider.delete_collection(self.user.id, coll.id)
+ return Response(status_code=204)
+ if method in ("GET", "HEAD"):
+ return Response(status_code=200)
+ return Response(status_code=405)
+
+ async def make_collection(self, segment: str, provider: DavProvider, coll_id: str) -> Response:
+ if any(c.id == coll_id for c in await provider.collections(self.user.id)):
+ raise DavError(405) # exists already
+ root = await self.body()
+ props = mkcol_props(root)
+ rtype = props.get(q(D, "resourcetype"))
+ if self.request.method.upper() == "MKCOL" and (
+ rtype is None
+ or rtype.find(q(CR, "addressbook")) is None
+ or provider.kind != "addressbook"
+ ):
+ raise DavError(403)
+ if self.request.method.upper() == "MKCALENDAR" and provider.kind != "calendar":
+ raise DavError(403)
+ name_el = props.get(q(D, "displayname"))
+ color_el = props.get(q(A, "calendar-color"))
+ await provider.create_collection(
+ self.user.id,
+ coll_id,
+ (name_el.text or "").strip() if name_el is not None else "",
+ (color_el.text or "").strip() if color_el is not None else None,
+ )
+ return Response(status_code=201)
+
+ async def proppatch(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
+ patch = proppatch_request(await self.body())
+ if coll.read_only:
+ raise DavError(403)
+ values: dict[str, Any] = {}
+ for name, value in patch.set.items():
+ text = (value.text or "").strip()
+ if name == q(D, "displayname"):
+ values["displayname"] = text
+ elif name == q(A, "calendar-color"):
+ values["color"] = text
+ elif name in (q(C, "calendar-description"), q(CR, "addressbook-description")):
+ values["description"] = text
+ if values:
+ await provider.update_collection(self.user.id, coll.id, **values)
+ # Properties not kept (calendar order, …) are accepted and ignored:
+ # clients treat a refusal as a failed sync.
+ ms = Multistatus()
+ ms.response(self.coll_href(segment, coll.id), dict.fromkeys([*patch.set, *patch.remove]))
+ return self.multistatus(ms)
+
+ async def report(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
+ root = await self.body()
+ if root is None:
+ raise BadRequest("empty report")
+ rep = report_request(root)
+ wants_data = rep.props is None or any(
+ p in (q(C, "calendar-data"), q(CR, "address-data")) for p in rep.props
+ )
+ ms = Multistatus()
+
+ def add(res: Resource) -> None:
+ props = self.resource_props(provider, res, wants_data)
+ if rep.props is None:
+ ms.response(self.res_href(segment, coll.id, res.name), props)
+ else:
+ found = {n: props[n] for n in rep.props if n in props}
+ missing = [n for n in rep.props if n not in props]
+ ms.response(self.res_href(segment, coll.id, res.name), found, missing)
+
+ if rep.kind in ("calendar-multiget", "addressbook-multiget"):
+ names = [_last_segment(h) for h in rep.hrefs]
+ got = {r.name: r for r in await provider.get(self.user.id, coll.id, names)}
+ for h, name in zip(rep.hrefs, names, strict=True):
+ if name in got:
+ add(got[name])
+ else:
+ ms.response(h, {}, status=404)
+ return self.multistatus(ms)
+
+ if rep.kind in ("calendar-query", "addressbook-query"):
+ names_q = (
+ await provider.query(self.user.id, coll.id, rep.start, rep.end)
+ if rep.kind == "calendar-query"
+ else None
+ )
+ for res in (
+ await provider.get(self.user.id, coll.id, names_q)
+ if wants_data
+ else (
+ await provider.resources(self.user.id, coll.id)
+ if names_q is None
+ else [
+ r
+ for r in await provider.resources(self.user.id, coll.id)
+ if r.name in set(names_q)
+ ]
+ )
+ ):
+ add(res)
+ return self.multistatus(ms)
+
+ # sync-collection (RFC 6578)
+ since = _token_number(rep.sync_token)
+ if rep.sync_token and since is None:
+ raise DavError(403, condition=q(D, "valid-sync-token"))
+ if since is None:
+ changed = None
+ deleted: list[str] = []
+ token = coll.sync_token
+ else:
+ changes = await provider.changes(self.user.id, coll.id, since)
+ if changes is None:
+ raise DavError(403, condition=q(D, "valid-sync-token"))
+ changed, deleted, token = changes.changed, changes.deleted, changes.sync_token
+ if changed is None:
+ resources = (
+ await provider.get(self.user.id, coll.id, None)
+ if wants_data
+ else await provider.resources(self.user.id, coll.id)
+ )
+ elif changed:
+ resources = (
+ await provider.get(self.user.id, coll.id, changed)
+ if wants_data
+ else [
+ r
+ for r in await provider.resources(self.user.id, coll.id)
+ if r.name in set(changed)
+ ]
+ )
+ else:
+ resources = []
+ present = {r.name for r in resources}
+ for res in resources:
+ add(res)
+ for name in deleted:
+ if name not in present:
+ ms.response(self.res_href(segment, coll.id, name), {}, status=404)
+ ms.sync_token(f"{SYNC_PREFIX}{token}")
+ return self.multistatus(ms)
+
+ # -- resources ---------------------------------------------------------------------
+
+ async def resource_req(
+ self, method: str, segment: str, provider: DavProvider, coll_id: str, name: str
+ ) -> Response:
+ coll = await self.find(provider, coll_id)
+ if method in ("GET", "HEAD"):
+ got = await provider.get(self.user.id, coll.id, [name])
+ if not got:
+ raise DavError(404)
+ res = got[0]
+ content = (res.data or "").encode() if method == "GET" else b""
+ return Response(
+ status_code=200,
+ content=content,
+ media_type=provider.content_type,
+ headers={"ETag": f'"{res.etag}"'},
+ )
+ if method == "PROPFIND":
+ got = await provider.get(self.user.id, coll.id, [name])
+ if not got:
+ raise DavError(404)
+ return await self.respond_props(
+ [
+ (
+ self.res_href(segment, coll.id, name),
+ self.resource_props(provider, got[0], True),
+ )
+ ],
+ await self.body(),
+ )
+ if method == "PUT":
+ if coll.read_only:
+ raise DavError(403, condition=q(D, "need-privileges"))
+ data = (await self.request.body()).decode("utf-8", "replace")
+ etag, created = await provider.put(
+ self.user.id,
+ coll.id,
+ name,
+ data,
+ if_match=_etag_header(self.request.headers.get("if-match")),
+ if_none_match=(self.request.headers.get("if-none-match") or "").strip() == "*",
+ )
+ return Response(status_code=201 if created else 204, headers={"ETag": f'"{etag}"'})
+ if method == "DELETE":
+ if coll.read_only:
+ raise DavError(403)
+ await provider.delete(
+ self.user.id,
+ coll.id,
+ name,
+ if_match=_etag_header(self.request.headers.get("if-match")),
+ )
+ return Response(status_code=204)
+ return Response(status_code=405)
+
+
+def _last_segment(h: str) -> str:
+ path = urlsplit(h).path
+ return unquote(path.rstrip("/").rsplit("/", 1)[-1])
+
+
+def _token_number(token: str) -> int | None:
+ if not token:
+ return None
+ tail = token.rsplit("/", 1)[-1]
+ return int(tail) if tail.isdigit() else None
+
+
+def _etag_header(value: str | None) -> str | None:
+ if not value:
+ return None
+ value = value.strip()
+ if value == "*":
+ return "*"
+ if value.startswith("W/"):
+ value = value[2:]
+ return value.strip('"')
+
+
+__all__ = ["router", "uuid"]
diff --git a/backend/app/dav/xml.py b/backend/app/dav/xml.py
new file mode 100644
index 0000000..3a64b0f
--- /dev/null
+++ b/backend/app/dav/xml.py
@@ -0,0 +1,236 @@
+"""WebDAV XML: reading request bodies, writing multistatus answers (RFC 4918,
+4791, 6352, 6578). Properties are ``{namespace}name`` strings, as ElementTree
+names them."""
+
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
+
+from defusedxml import ElementTree as SafeET
+
+D = "DAV:"
+C = "urn:ietf:params:xml:ns:caldav"
+CR = "urn:ietf:params:xml:ns:carddav"
+CS = "http://calendarserver.org/ns/"
+A = "http://apple.com/ns/ical/"
+
+for _prefix, _ns in (("d", D), ("cal", C), ("card", CR), ("cs", CS), ("ical", A)):
+ ET.register_namespace(_prefix, _ns)
+
+
+def q(ns: str, name: str) -> str:
+ return f"{{{ns}}}{name}"
+
+
+class BadRequest(Exception):
+ pass
+
+
+def parse(body: bytes) -> ET.Element | None:
+ if not body.strip():
+ return None
+ try:
+ root = SafeET.fromstring(body)
+ except Exception as err: # noqa: BLE001 - any malformed XML is the client's error
+ raise BadRequest(str(err)) from err
+ return root # type: ignore[no-any-return]
+
+
+@dataclass
+class PropRequest:
+ """PROPFIND: which properties (None = all)."""
+
+ names: list[str] | None = None
+ names_only: bool = False
+
+
+def propfind_request(root: ET.Element | None) -> PropRequest:
+ if root is None or root.find(q(D, "allprop")) is not None:
+ return PropRequest()
+ if root.find(q(D, "propname")) is not None:
+ return PropRequest(names_only=True)
+ prop = root.find(q(D, "prop"))
+ if prop is None:
+ return PropRequest()
+ return PropRequest(names=[child.tag for child in prop])
+
+
+def requested_props(root: ET.Element) -> list[str] | None:
+ prop = root.find(q(D, "prop"))
+ if prop is None:
+ return None
+ return [child.tag for child in prop]
+
+
+@dataclass
+class Report:
+ # calendar-query | calendar-multiget | addressbook-query | addressbook-multiget
+ # | sync-collection
+ kind: str
+ props: list[str] | None
+ hrefs: list[str] = field(default_factory=list)
+ start: datetime | None = None
+ end: datetime | None = None
+ sync_token: str = ""
+ limit: int | None = None
+
+
+def _ical_time(value: str | None) -> datetime | None:
+ if not value:
+ return None
+ try:
+ return datetime.strptime(value.strip(), "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
+ except ValueError:
+ return None
+
+
+def report_request(root: ET.Element) -> Report:
+ tag = root.tag
+ names = {
+ q(C, "calendar-query"): "calendar-query",
+ q(C, "calendar-multiget"): "calendar-multiget",
+ q(CR, "addressbook-query"): "addressbook-query",
+ q(CR, "addressbook-multiget"): "addressbook-multiget",
+ q(D, "sync-collection"): "sync-collection",
+ }
+ kind = names.get(tag)
+ if kind is None:
+ raise BadRequest(f"unsupported report {tag}")
+ rep = Report(kind=kind, props=requested_props(root))
+ rep.hrefs = [(h.text or "").strip() for h in root.findall(q(D, "href"))]
+ if kind == "calendar-query":
+ tr = root.find(f".//{q(C, 'time-range')}")
+ if tr is not None:
+ rep.start = _ical_time(tr.get("start"))
+ rep.end = _ical_time(tr.get("end"))
+ if kind == "sync-collection":
+ rep.sync_token = (root.findtext(q(D, "sync-token")) or "").strip()
+ nresults = root.find(f"{q(D, 'limit')}/{q(D, 'nresults')}")
+ if nresults is not None and (nresults.text or "").strip().isdigit():
+ rep.limit = int((nresults.text or "").strip())
+ return rep
+
+
+@dataclass
+class PropPatch:
+ set: dict[str, ET.Element] = field(default_factory=dict)
+ remove: list[str] = field(default_factory=list)
+
+
+def proppatch_request(root: ET.Element | None) -> PropPatch:
+ out = PropPatch()
+ if root is None:
+ return out
+ for action in root:
+ prop = action.find(q(D, "prop"))
+ if prop is None:
+ continue
+ for child in prop:
+ if action.tag == q(D, "set"):
+ out.set[child.tag] = child
+ elif action.tag == q(D, "remove"):
+ out.remove.append(child.tag)
+ return out
+
+
+def mkcol_props(root: ET.Element | None) -> dict[str, ET.Element]:
+ """MKCALENDAR / extended MKCOL: the properties to set on the new collection."""
+ if root is None:
+ return {}
+ out: dict[str, ET.Element] = {}
+ for sett in root.findall(q(D, "set")):
+ prop = sett.find(q(D, "prop"))
+ if prop is not None:
+ for child in prop:
+ out[child.tag] = child
+ return out
+
+
+# ---- answers --------------------------------------------------------------------------
+
+# A property's value: text, a list of child elements, or an element builder.
+PropValue = str | list[ET.Element] | None
+
+
+class Multistatus:
+ def __init__(self) -> None:
+ self.root = ET.Element(q(D, "multistatus"))
+
+ def response(
+ self,
+ href: str,
+ found: dict[str, PropValue],
+ missing: list[str] | None = None,
+ status: int | None = None,
+ ) -> None:
+ resp = ET.SubElement(self.root, q(D, "response"))
+ ET.SubElement(resp, q(D, "href")).text = href
+ if status is not None:
+ ET.SubElement(resp, q(D, "status")).text = _status_line(status)
+ return
+ if found:
+ ps = ET.SubElement(resp, q(D, "propstat"))
+ prop = ET.SubElement(ps, q(D, "prop"))
+ for name, value in found.items():
+ el = ET.SubElement(prop, name)
+ if isinstance(value, str):
+ el.text = value
+ elif isinstance(value, list):
+ el.extend(value)
+ ET.SubElement(ps, q(D, "status")).text = _status_line(200)
+ if missing:
+ ps = ET.SubElement(resp, q(D, "propstat"))
+ prop = ET.SubElement(ps, q(D, "prop"))
+ for name in missing:
+ ET.SubElement(prop, name)
+ ET.SubElement(ps, q(D, "status")).text = _status_line(404)
+
+ def sync_token(self, token: str) -> None:
+ ET.SubElement(self.root, q(D, "sync-token")).text = token
+
+ def to_bytes(self) -> bytes:
+ body: bytes = ET.tostring(self.root, encoding="utf-8")
+ return b'\n' + body
+
+
+def el(
+ tag: str,
+ text: str | None = None,
+ children: list[ET.Element] | None = None,
+ attrs: dict[str, str] | None = None,
+) -> ET.Element:
+ e = ET.Element(tag, attrs or {})
+ if text is not None:
+ e.text = text
+ if children:
+ e.extend(children)
+ return e
+
+
+def href(path: str) -> ET.Element:
+ return el(q(D, "href"), path)
+
+
+def error_body(condition: str) -> bytes:
+ root = ET.Element(q(D, "error"))
+ ET.SubElement(root, condition)
+ return b'\n' + bytes(ET.tostring(root, encoding="utf-8"))
+
+
+_REASONS = {
+ 200: "OK",
+ 201: "Created",
+ 204: "No Content",
+ 207: "Multi-Status",
+ 403: "Forbidden",
+ 404: "Not Found",
+ 409: "Conflict",
+ 412: "Precondition Failed",
+ 507: "Insufficient Storage",
+}
+
+
+def _status_line(code: int) -> str:
+ return f"HTTP/1.1 {code} {_REASONS.get(code, '')}".rstrip()
diff --git a/backend/app/identity/app_passwords.py b/backend/app/identity/app_passwords.py
new file mode 100644
index 0000000..3a84071
--- /dev/null
+++ b/backend/app/identity/app_passwords.py
@@ -0,0 +1,104 @@
+"""App passwords: one generated password per mail app (SPEC App. D §D.5).
+
+A mail app (Thunderbird, a phone's Mail app) signs in to the hosted mailbox
+over IMAP/SMTP, where no 2FA code can be asked for. It gets a password of its
+own — 16 random letters, shown once — that can be revoked without touching the
+main password or the other apps. The webmail itself never accepts one.
+
+Stored as argon2id hashes (identity/passwords.py), the form the mail server's
+SQL directory reads (§D.3). The hash is of the bare 16 letters: the mail
+server compares exactly what an app sends, so the password is copied and
+entered without the spaces it is shown with.
+"""
+
+from __future__ import annotations
+
+import secrets
+import string
+import uuid
+
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.identity.passwords import hash_password, verify_password
+from app.models import UserAppPassword
+
+LENGTH = 16 # 26^16 ≈ 2^75
+MAX_PER_USER = 25
+NAME_MAX = 60
+_ALPHABET = string.ascii_lowercase
+
+
+def generation_key(user_id: uuid.UUID) -> str:
+ """Redis counter bumped whenever the user's app passwords change: DAV
+ sign-ins cached under the old value stop counting (app/dav/auth.py)."""
+ return f"apppw:gen:{user_id}"
+
+
+async def _changed(user_id: uuid.UUID) -> None:
+ from app.redis_client import get_redis
+
+ await get_redis().incr(generation_key(user_id))
+
+
+class TooMany(Exception):
+ """The user already has MAX_PER_USER app passwords."""
+
+
+def generate() -> str:
+ return "".join(secrets.choice(_ALPHABET) for _ in range(LENGTH))
+
+
+async def list_for(db: AsyncSession, user_id: uuid.UUID) -> list[UserAppPassword]:
+ rows = await db.scalars(
+ select(UserAppPassword)
+ .where(UserAppPassword.user_id == user_id)
+ .order_by(UserAppPassword.created_at.desc())
+ )
+ return list(rows)
+
+
+async def create(db: AsyncSession, user_id: uuid.UUID, name: str) -> tuple[UserAppPassword, str]:
+ """A new app password named ``name``; returns the row and the password
+ itself, which is never available again."""
+ count = await db.scalar(
+ select(func.count()).select_from(UserAppPassword).where(UserAppPassword.user_id == user_id)
+ )
+ if (count or 0) >= MAX_PER_USER:
+ raise TooMany
+ password = generate()
+ row = UserAppPassword(
+ user_id=user_id, name=name.strip()[:NAME_MAX], password_hash=hash_password(password)
+ )
+ db.add(row)
+ await db.commit()
+ return row, password
+
+
+async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUID) -> bool:
+ result = await db.execute(
+ delete(UserAppPassword).where(
+ UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
+ )
+ )
+ await db.commit()
+ await _changed(user_id)
+ return bool(result.rowcount) # type: ignore[attr-defined]
+
+
+async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None:
+ """Every app signs in again (the main password was reset)."""
+ await db.execute(delete(UserAppPassword).where(UserAppPassword.user_id == user_id))
+ await db.commit()
+ await _changed(user_id)
+
+
+async def verify(db: AsyncSession, user_id: uuid.UUID, password: str) -> UserAppPassword | None:
+ """The app password ``password`` matches, if any — for the mail server's
+ sign-in and for tests; never for the webmail's own sign-in."""
+ if len(password) != LENGTH:
+ return None
+ for row in await list_for(db, user_id):
+ if verify_password(row.password_hash, password):
+ return row
+ return None
diff --git a/backend/app/identity/routes.py b/backend/app/identity/routes.py
index 00a8a30..1cbbeee 100644
--- a/backend/app/identity/routes.py
+++ b/backend/app/identity/routes.py
@@ -16,8 +16,8 @@ from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session
from app.events import get_event_bus
+from app.identity import app_passwords, trusted_devices
from app.identity import totp as totp_mod
-from app.identity import trusted_devices
from app.identity.cookies import (
clear_auth_cookies,
new_csrf_token,
@@ -34,6 +34,9 @@ from app.identity.reset import (
request_allowed,
)
from app.identity.schemas import (
+ AppPasswordCreatedOut,
+ AppPasswordIn,
+ AppPasswordOut,
LoginRequest,
MessageOut,
PasswordResetCheckIn,
@@ -312,12 +315,68 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
user.password_hash = hash_password(body.password)
await db.commit()
await destroy_all_sessions(user.id) # force re-login everywhere after reset
+ # Mail apps sign in again too: the reset may be the owner taking the account back.
+ await app_passwords.revoke_all(db, user.id)
# Whoever reset the password may be the owner taking the account back: no
# device keeps skipping the code on the old trust.
await trusted_devices.revoke_all(user.id)
return MessageOut()
+# --- App passwords (SPEC App. D §D.5) -----------------------------------------
+
+
+def _app_password_out(row: object) -> AppPasswordOut:
+ from app.models import UserAppPassword
+
+ assert isinstance(row, UserAppPassword)
+ return AppPasswordOut(
+ id=str(row.id),
+ name=row.name,
+ created_at=row.created_at.isoformat(),
+ last_used_at=row.last_used_at.isoformat() if row.last_used_at else None,
+ )
+
+
+@router.get("/app-passwords", response_model=list[AppPasswordOut])
+async def list_app_passwords(user: CurrentUser, db: DbSession) -> list[AppPasswordOut]:
+ return [_app_password_out(r) for r in await app_passwords.list_for(db, user.id)]
+
+
+@router.post(
+ "/app-passwords", response_model=AppPasswordCreatedOut, dependencies=[Depends(require_csrf)]
+)
+async def create_app_password(
+ body: AppPasswordIn, user: CurrentUser, db: DbSession
+) -> AppPasswordCreatedOut:
+ """A new password for one mail app. Takes the account password: an app
+ password opens the mailbox without a 2FA code."""
+ if not verify_password(user.password_hash, body.password):
+ raise_api_error(403, "invalid_password", "incorrect password")
+ if not body.name.strip():
+ raise_api_error(400, "invalid_name", "give the app password a name")
+ try:
+ row, password = await app_passwords.create(db, user.id, body.name)
+ except app_passwords.TooMany:
+ raise_api_error(
+ 409, "too_many", f"you can have up to {app_passwords.MAX_PER_USER} app passwords"
+ )
+ return AppPasswordCreatedOut(**_app_password_out(row).model_dump(), app_password=password)
+
+
+@router.delete(
+ "/app-passwords/{app_password_id}",
+ response_model=MessageOut,
+ dependencies=[Depends(require_csrf)],
+)
+async def revoke_app_password(
+ app_password_id: uuid.UUID, user: CurrentUser, db: DbSession
+) -> MessageOut:
+ if not await app_passwords.revoke(db, user.id, app_password_id):
+ raise_api_error(404, "not_found", "app password not found")
+ return MessageOut()
+
+
@router.put("/recovery-email", response_model=UserOut, dependencies=[Depends(require_csrf)])
async def set_recovery_email(body: RecoveryEmailIn, user: CurrentUser, db: DbSession) -> UserOut:
"""Add, change or remove the recovery address. Takes the password: whoever
diff --git a/backend/app/identity/schemas.py b/backend/app/identity/schemas.py
index 1fb5f96..7af1bb6 100644
--- a/backend/app/identity/schemas.py
+++ b/backend/app/identity/schemas.py
@@ -63,6 +63,23 @@ class PasswordResetCheckOut(BaseModel):
totp_required: bool = False
+class AppPasswordIn(BaseModel):
+ name: str = Field(min_length=1, max_length=60) # "Thunderbird on laptop"
+ password: str = Field(min_length=1, max_length=1024) # the account password, to confirm
+
+
+class AppPasswordOut(BaseModel):
+ id: str
+ name: str
+ created_at: str
+ last_used_at: str | None = None
+
+
+class AppPasswordCreatedOut(AppPasswordOut):
+ # The generated password itself: in this answer only, never again.
+ app_password: str
+
+
class RecoveryEmailIn(BaseModel):
password: str = Field(min_length=1, max_length=1024)
# None (or blank) removes the recovery address.
diff --git a/backend/app/models.py b/backend/app/models.py
index 1509426..3c4f604 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -353,6 +353,22 @@ class SavedSearch(Base):
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true"))
+class UserAppPassword(Base):
+ """A password for one mail app (IMAP/SMTP), revocable on its own (§D.5)."""
+
+ __tablename__ = "user_app_passwords"
+
+ id: Mapped[uuid.UUID] = _uuid_pk()
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ name: Mapped[str] = mapped_column(Text, nullable=False) # "Thunderbird on laptop"
+ password_hash: Mapped[str] = mapped_column(Text, nullable=False) # argon2id
+ created_at: Mapped[dt.datetime] = _now()
+ # Set by the mail server's sign-in, once there is one.
+ last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
+
+
class PushSubscription(Base):
"""A browser or installed app to tell about new mail (Web Push)."""
diff --git a/backend/app/plugins/types.py b/backend/app/plugins/types.py
index 6e9f463..0707c5d 100644
--- a/backend/app/plugins/types.py
+++ b/backend/app/plugins/types.py
@@ -112,6 +112,9 @@ class Plugin:
# Core plugins (calendar, contacts) ship builtin=True: always installed, never
# offered for removal in the UI, and the uninstall path refuses --drop-data.
builtin: bool = False
+ # Serves its collections to CalDAV / CardDAV clients (app/dav/provider.py
+ # DavProvider; SPEC Appendix E).
+ dav: Any = None
def __post_init__(self) -> None:
if not _NAME_RE.fullmatch(self.name):
diff --git a/backend/migrations/versions/0024_app_passwords.py b/backend/migrations/versions/0024_app_passwords.py
new file mode 100644
index 0000000..05fa267
--- /dev/null
+++ b/backend/migrations/versions/0024_app_passwords.py
@@ -0,0 +1,43 @@
+"""app passwords: one password per mail app (SPEC App. D §D.5)
+
+Mail apps (Thunderbird, a phone's Mail app) will sign in to the user's hosted
+mailbox over IMAP/SMTP, where no 2FA code can be asked for. Each app gets its
+own generated password instead, which can be revoked on its own; the main
+password with 2FA stays for the webmail. Stored as argon2id hashes, the form
+the mail server's SQL directory reads (§D.3).
+
+Revision ID: 0024
+Revises: 0023
+Create Date: 2026-10-09
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "0024"
+down_revision: str | None = "0023"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.execute(
+ """
+ CREATE TABLE user_app_passwords (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name text NOT NULL,
+ password_hash text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ last_used_at timestamptz
+ )
+ """
+ )
+ op.execute("CREATE INDEX ix_user_app_passwords_user ON user_app_passwords (user_id)")
+
+
+def downgrade() -> None:
+ op.execute("DROP TABLE IF EXISTS user_app_passwords")
diff --git a/backend/tests/identity/test_app_passwords.py b/backend/tests/identity/test_app_passwords.py
new file mode 100644
index 0000000..0a0a3fc
--- /dev/null
+++ b/backend/tests/identity/test_app_passwords.py
@@ -0,0 +1,138 @@
+"""App passwords (SPEC App. D §D.5): one generated password per mail app,
+shown once, revocable on its own, never accepted by the webmail's sign-in."""
+
+from __future__ import annotations
+
+import re
+import uuid
+from collections.abc import AsyncIterator
+from dataclasses import dataclass, field
+
+import httpx
+import pytest
+import pytest_asyncio
+from httpx import ASGITransport
+from sqlalchemy import delete, select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.pool import NullPool
+
+from app.config import get_settings
+from app.identity import app_passwords
+from app.identity.passwords import hash_password
+from app.identity.reset import issue_token
+from app.identity.sessions import create_session
+from app.main import create_app
+from app.models import User, UserAppPassword
+
+CSRF = "csrf-token"
+PASSWORD = "hunter2pass"
+
+
+@dataclass
+class Ctx:
+ db: AsyncSession
+ anon: httpx.AsyncClient
+ created: list[uuid.UUID] = field(default_factory=list)
+
+ async def user(self) -> User:
+ user = User(
+ handle=f"ap{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password(PASSWORD)
+ )
+ self.db.add(user)
+ await self.db.commit()
+ self.created.append(user.id)
+ return user
+
+ async def signed_in(self, user: User) -> httpx.AsyncClient:
+ token = await create_session(user.id, "pytest")
+ return httpx.AsyncClient(
+ transport=ASGITransport(app=create_app()),
+ base_url="http://testserver",
+ cookies={"session": token, "csrf": CSRF},
+ headers={"X-CSRF": CSRF},
+ )
+
+
+@pytest_asyncio.fixture
+async def ctx() -> AsyncIterator[Ctx]:
+ engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
+ db = async_sessionmaker(engine, expire_on_commit=False)()
+ anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
+ c = Ctx(db=db, anon=anon)
+ try:
+ yield c
+ finally:
+ await anon.aclose()
+ await db.execute(delete(User).where(User.id.in_(c.created)))
+ await db.commit()
+ await db.close()
+ await engine.dispose()
+
+
+async def _create(client: httpx.AsyncClient, name: str, password: str = PASSWORD) -> httpx.Response:
+ return await client.post("/api/auth/app-passwords", json={"name": name, "password": password})
+
+
+async def test_created_once_listed_without_secret_and_revoked(ctx: Ctx) -> None:
+ user = await ctx.user()
+ client = await ctx.signed_in(user)
+
+ assert (await _create(client, "Thunderbird", "wrong")).status_code == 403
+ made = await _create(client, " Thunderbird on laptop ")
+ assert made.status_code == 200
+ body = made.json()
+ assert body["name"] == "Thunderbird on laptop"
+ assert re.fullmatch(r"[a-z]{16}", body["app_password"])
+
+ listed = (await client.get("/api/auth/app-passwords")).json()
+ assert [a["name"] for a in listed] == ["Thunderbird on laptop"]
+ assert "app_password" not in listed[0] and "password_hash" not in listed[0]
+ assert listed[0]["last_used_at"] is None
+
+ # What the mail server will check: this one matches, nothing else does.
+ row = await app_passwords.verify(ctx.db, user.id, body["app_password"])
+ assert row is not None and str(row.id) == body["id"]
+ assert await app_passwords.verify(ctx.db, user.id, PASSWORD) is None
+ assert await app_passwords.verify(ctx.db, user.id, "a" * 16) is None
+ stored = await ctx.db.scalar(
+ select(UserAppPassword.password_hash).where(UserAppPassword.id == row.id)
+ )
+ assert stored and stored.startswith("$argon2id$") and body["app_password"] not in stored
+
+ # The webmail's own sign-in never takes an app password.
+ login = await ctx.anon.post(
+ "/api/auth/login", json={"handle": user.handle, "password": body["app_password"]}
+ )
+ assert login.status_code == 401
+
+ # Someone else cannot revoke it; its owner can, once.
+ other = await ctx.signed_in(await ctx.user())
+ assert (await other.delete(f"/api/auth/app-passwords/{body['id']}")).status_code == 404
+ assert (await client.delete(f"/api/auth/app-passwords/{body['id']}")).status_code == 200
+ assert (await client.delete(f"/api/auth/app-passwords/{body['id']}")).status_code == 404
+ assert await app_passwords.verify(ctx.db, user.id, body["app_password"]) is None
+ await client.aclose()
+ await other.aclose()
+
+
+async def test_at_most_a_few_per_user(ctx: Ctx, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(app_passwords, "MAX_PER_USER", 2)
+ client = await ctx.signed_in(await ctx.user())
+ assert (await _create(client, "one")).status_code == 200
+ assert (await _create(client, "two")).status_code == 200
+ third = await _create(client, "three")
+ assert third.status_code == 409 and third.json()["error"]["code"] == "too_many"
+ await client.aclose()
+
+
+async def test_password_reset_revokes_them_all(ctx: Ctx) -> None:
+ user = await ctx.user()
+ client = await ctx.signed_in(user)
+ secret = (await _create(client, "Phone")).json()["app_password"]
+ token = await issue_token(user.id)
+ reset = await ctx.anon.post(
+ "/api/auth/password/reset", json={"token": token, "password": "brandNewPass9"}
+ )
+ assert reset.status_code == 200
+ assert await app_passwords.verify(ctx.db, user.id, secret) is None
+ await client.aclose()
diff --git a/backend/tests/plugins/test_calendar_dav.py b/backend/tests/plugins/test_calendar_dav.py
new file mode 100644
index 0000000..b079a33
--- /dev/null
+++ b/backend/tests/plugins/test_calendar_dav.py
@@ -0,0 +1,231 @@
+"""CalDAV server (SPEC Appendix E): discovery, sign-in with app passwords, and
+two-way changes between DAV clients and the web app on the same rows."""
+
+from __future__ import annotations
+
+import base64
+import re
+import uuid
+from collections.abc import AsyncIterator
+from dataclasses import dataclass
+
+import httpx
+import pytest_asyncio
+from httpx import ASGITransport
+from sqlalchemy import delete, text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+from sqlalchemy.pool import NullPool
+
+from app.config import get_settings
+from app.db import session_scope
+from app.identity import app_passwords
+from app.identity.passwords import hash_password
+from app.identity.sessions import create_session
+from app.main import create_app
+from app.models import User
+
+CAL = "/api/plugins/calendar"
+PROPFIND_ALL = b''
+
+EVENT = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//test//EN
+BEGIN:VEVENT
+UID:{uid}
+DTSTAMP:20261009T120000Z
+DTSTART;TZID=Europe/Warsaw:20311013T090000
+DTEND;TZID=Europe/Warsaw:20311013T100000
+SUMMARY:{summary}
+X-CLIENT-ONLY:keep me
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:x
+TRIGGER:-PT30M
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+"""
+
+
+@dataclass
+class Ctx:
+ dav: httpx.AsyncClient # a CalDAV client (Basic, app password)
+ web: httpx.AsyncClient # the web app (session)
+ anon: httpx.AsyncClient
+ user: User
+ app_password: str
+ app_password_id: uuid.UUID
+ home: str
+ calendar_id: str
+
+
+def _basic(login: str, password: str) -> dict[str, str]:
+ return {"Authorization": "Basic " + base64.b64encode(f"{login}:{password}".encode()).decode()}
+
+
+@pytest_asyncio.fixture
+async def ctx() -> AsyncIterator[Ctx]:
+ engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
+ sm = async_sessionmaker(engine, expire_on_commit=False)
+ async with sm() as s:
+ user = User(
+ handle=f"dav{uuid.uuid4().hex[:8]}@test.local",
+ password_hash=hash_password("account-pw"),
+ )
+ s.add(user)
+ await s.commit()
+ async with session_scope() as db:
+ row, password = await app_passwords.create(db, user.id, "test client")
+ transport = ASGITransport(app=create_app())
+ token = await create_session(user.id, "pytest")
+ web = httpx.AsyncClient(
+ transport=transport,
+ base_url="http://testserver",
+ cookies={"session": token, "csrf": "c"},
+ headers={"X-CSRF": "c"},
+ )
+ calendar_id = (await web.get(f"{CAL}/calendars")).json()[0]["id"] # bootstraps "Personal"
+ dav = httpx.AsyncClient(
+ transport=transport, base_url="http://testserver", headers=_basic(user.handle, password)
+ )
+ anon = httpx.AsyncClient(transport=transport, base_url="http://testserver")
+ try:
+ yield Ctx(dav, web, anon, user, password, row.id, f"/dav/calendars/{user.id}/", calendar_id)
+ finally:
+ for c in (dav, web, anon):
+ await c.aclose()
+ async with sm() as s:
+ await s.execute(
+ text("DELETE FROM plg_calendar_calendars WHERE user_id = :u"), {"u": str(user.id)}
+ )
+ await s.execute(
+ text("DELETE FROM plg_calendar_dav_changes WHERE user_id = :u"), {"u": str(user.id)}
+ )
+ await s.execute(delete(User).where(User.id == user.id))
+ await s.commit()
+ await engine.dispose()
+
+
+def _sync_report(token: str = "") -> bytes:
+ return (
+ ''
+ f"{token}1"
+ ""
+ ).encode()
+
+
+async def _sync(c: Ctx, token: str = "") -> tuple[str, str]:
+ r = await c.dav.request("REPORT", f"{c.home}{c.calendar_id}/", content=_sync_report(token))
+ assert r.status_code == 207, r.text
+ new = re.search(r"([^<]+)", r.text)
+ assert new
+ return new.group(1), r.text
+
+
+async def test_discovery_and_sign_in(ctx: Ctx) -> None:
+ assert (await ctx.anon.get("/.well-known/caldav")).headers["location"] == "/dav/"
+ assert (await ctx.anon.request("PROPFIND", "/dav/")).status_code == 401
+ # The account password never works here — only app passwords.
+ wrong = await ctx.anon.request(
+ "PROPFIND", "/dav/", headers=_basic(ctx.user.handle, "account-pw")
+ )
+ assert wrong.status_code == 401
+ root = await ctx.dav.request("PROPFIND", "/dav/", headers={"Depth": "0"}, content=PROPFIND_ALL)
+ assert root.status_code == 207 and f"/dav/principals/{ctx.user.id}/" in root.text
+ principal = await ctx.dav.request(
+ "PROPFIND", f"/dav/principals/{ctx.user.id}/", headers={"Depth": "0"}
+ )
+ assert ctx.home in principal.text and "calendar-home-set" in principal.text
+ home = await ctx.dav.request("PROPFIND", ctx.home, headers={"Depth": "1"})
+ assert (
+ home.status_code == 207 and "Personal" in home.text and f"{ctx.calendar_id}/" in home.text
+ )
+ # Someone else's home: refused.
+ other = await ctx.dav.request(
+ "PROPFIND", f"/dav/calendars/{uuid.uuid4()}/", headers={"Depth": "1"}
+ )
+ assert other.status_code == 403
+ # Revoking the app password locks the client out at once (no stale cache).
+ async with session_scope() as db:
+ await app_passwords.revoke(db, ctx.user.id, ctx.app_password_id)
+ assert (await ctx.dav.request("PROPFIND", "/dav/")).status_code == 401
+
+
+async def test_client_and_web_app_see_each_others_changes(ctx: Ctx) -> None:
+ url = f"{ctx.home}{ctx.calendar_id}/review.ics"
+ token0, _ = await _sync(ctx)
+
+ # A client adds an event: the web app shows it, alarm included.
+ put = await ctx.dav.put(
+ url,
+ content=EVENT.format(uid="dav-1@test", summary="Review"),
+ headers={"If-None-Match": "*"},
+ )
+ assert put.status_code == 201
+ etag = put.headers["etag"]
+ events = (
+ await ctx.web.get(
+ f"{CAL}/events", params={"start": "2031-10-13T00:00:00Z", "end": "2031-10-14T00:00:00Z"}
+ )
+ ).json()["events"]
+ assert [(e["summary"], e["reminders"], e["tz"]) for e in events] == [
+ ("Review", [30], "Europe/Warsaw")
+ ]
+ event_id = events[0]["id"]
+ got = await ctx.dav.get(url)
+ assert got.headers["etag"] == etag and "X-CLIENT-ONLY:keep me" in got.text # verbatim
+ token1, body = await _sync(ctx, token0)
+ assert "review.ics" in body
+
+ # Creating it again, or editing an old version, is refused.
+ again = await ctx.dav.put(
+ url, content=EVENT.format(uid="dav-1@test", summary="x"), headers={"If-None-Match": "*"}
+ )
+ assert again.status_code == 412
+ stale = await ctx.dav.put(
+ url, content=EVENT.format(uid="dav-1@test", summary="x"), headers={"If-Match": '"nope"'}
+ )
+ assert stale.status_code == 412
+
+ # The web app renames it: the client gets the new version (regenerated).
+ assert (
+ await ctx.web.patch(f"{CAL}/events/{event_id}", json={"summary": "Review (web)"})
+ ).status_code == 200
+ token2, body = await _sync(ctx, token1)
+ assert "review.ics" in body and token2 != token1
+ got = await ctx.dav.get(url)
+ assert got.headers["etag"] != etag and "SUMMARY:Review (web)" in got.text
+ assert "TRIGGER:-PT30M" in got.text and "TZID=Europe/Warsaw" in got.text
+
+ # The web app deletes it: the client hears it is gone.
+ assert (await ctx.web.delete(f"{CAL}/events/{event_id}")).status_code == 200
+ _, body = await _sync(ctx, token2)
+ assert "review.ics" in body and "404" in body
+ assert (await ctx.dav.get(url)).status_code == 404
+
+
+async def test_client_delete_and_read_only_calendars(ctx: Ctx) -> None:
+ url = f"{ctx.home}{ctx.calendar_id}/gone.ics"
+ put = await ctx.dav.put(url, content=EVENT.format(uid="dav-2@test", summary="Temp"))
+ assert put.status_code == 201
+ assert (await ctx.dav.delete(url, headers={"If-Match": put.headers["etag"]})).status_code == 204
+ events = (
+ await ctx.web.get(
+ f"{CAL}/events", params={"start": "2031-10-13T00:00:00Z", "end": "2031-10-14T00:00:00Z"}
+ )
+ ).json()["events"]
+ assert events == []
+
+ # A subscribed (read-only) calendar refuses writes.
+ async with session_scope() as db:
+ sub = await db.scalar(
+ text(
+ "INSERT INTO plg_calendar_calendars (user_id, name, kind, read_only) "
+ "VALUES (:u, 'Holidays', 'ics_subscription', true) RETURNING id"
+ ),
+ {"u": str(ctx.user.id)},
+ )
+ ro = await ctx.dav.put(
+ f"{ctx.home}{sub}/x.ics", content=EVENT.format(uid="dav-3@test", summary="x")
+ )
+ assert ro.status_code == 403
diff --git a/backend/tests/plugins/test_contacts_dav.py b/backend/tests/plugins/test_contacts_dav.py
new file mode 100644
index 0000000..9aa69aa
--- /dev/null
+++ b/backend/tests/plugins/test_contacts_dav.py
@@ -0,0 +1,160 @@
+"""CardDAV server (SPEC Appendix E.4): address books, vCards in and out, groups
+as CATEGORIES, and changes flowing both ways with the web app."""
+
+from __future__ import annotations
+
+import base64
+import re
+import uuid
+from collections.abc import AsyncIterator
+from dataclasses import dataclass
+
+import httpx
+import pytest_asyncio
+from httpx import ASGITransport
+from sqlalchemy import delete, text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+from sqlalchemy.pool import NullPool
+
+from app.config import get_settings
+from app.db import session_scope
+from app.identity import app_passwords
+from app.identity.passwords import hash_password
+from app.identity.sessions import create_session
+from app.main import create_app
+from app.models import User
+
+CON = "/api/plugins/contacts"
+IPHONE_CARD = (
+ "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//iPhone OS 18.0//EN\r\n"
+ "N:Kowalska;Anna;;;\r\nFN:Anna Kowalska\r\nORG:Imaginary Pixels;Compositing\r\n"
+ "item1.EMAIL;type=INTERNET;type=WORK;type=pref:anna@ip.example\r\n"
+ "TEL;type=CELL;type=VOICE:+48 600 100 200\r\nCATEGORIES:Clients\r\n"
+ "X-ABUID:ABC123\r\nUID:card-1\r\nEND:VCARD\r\n"
+)
+
+
+@dataclass
+class Ctx:
+ dav: httpx.AsyncClient
+ web: httpx.AsyncClient
+ user: User
+ home: str
+ book_id: str
+
+
+@pytest_asyncio.fixture
+async def ctx() -> AsyncIterator[Ctx]:
+ engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
+ sm = async_sessionmaker(engine, expire_on_commit=False)
+ async with sm() as s:
+ user = User(
+ handle=f"card{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
+ )
+ s.add(user)
+ await s.commit()
+ async with session_scope() as db:
+ _, password = await app_passwords.create(db, user.id, "test client")
+ transport = ASGITransport(app=create_app())
+ token = await create_session(user.id, "pytest")
+ web = httpx.AsyncClient(
+ transport=transport,
+ base_url="http://testserver",
+ cookies={"session": token, "csrf": "c"},
+ headers={"X-CSRF": "c"},
+ )
+ auth = "Basic " + base64.b64encode(f"{user.handle}:{password}".encode()).decode()
+ dav = httpx.AsyncClient(
+ transport=transport, base_url="http://testserver", headers={"Authorization": auth}
+ )
+ home = f"/dav/addressbooks/{user.id}/"
+ listing = await dav.request("PROPFIND", home, headers={"Depth": "1"})
+ book_id = re.findall(rf"{home}([0-9a-f-]{{36}})/", listing.text)[0]
+ try:
+ yield Ctx(dav, web, user, home, book_id)
+ finally:
+ await dav.aclose()
+ await web.aclose()
+ async with sm() as s:
+ for table in ("plg_contacts_books", "plg_contacts_groups", "plg_contacts_dav_changes"):
+ await s.execute(
+ text(f"DELETE FROM {table} WHERE user_id = :u"), {"u": str(user.id)}
+ )
+ await s.execute(delete(User).where(User.id == user.id))
+ await s.commit()
+ await engine.dispose()
+
+
+async def _sync(c: Ctx, token: str = "") -> tuple[str, str]:
+ body = (
+ ''
+ f"{token}1"
+ ""
+ )
+ r = await c.dav.request("REPORT", f"{c.home}{c.book_id}/", content=body)
+ assert r.status_code == 207, r.text
+ m = re.search(r"([^<]+)", r.text)
+ assert m
+ return m.group(1), r.text
+
+
+async def test_discovery_finds_the_address_book(ctx: Ctx) -> None:
+ principal = await ctx.dav.request(
+ "PROPFIND", f"/dav/principals/{ctx.user.id}/", headers={"Depth": "0"}
+ )
+ assert "addressbook-home-set" in principal.text and ctx.home in principal.text
+ book = await ctx.dav.request("PROPFIND", f"{ctx.home}{ctx.book_id}/", headers={"Depth": "0"})
+ assert "Contacts" in book.text and "addressbook" in book.text
+
+
+async def test_a_phone_contact_and_the_web_app(ctx: Ctx) -> None:
+ url = f"{ctx.home}{ctx.book_id}/ABC123.vcf"
+ token0, _ = await _sync(ctx)
+ put = await ctx.dav.put(url, content=IPHONE_CARD, headers={"If-None-Match": "*"})
+ assert put.status_code == 201
+
+ # The web app shows it, in the group its category named.
+ listed = (await ctx.web.get(f"{CON}/contacts", params={"filter": "all", "q": ""})).json()[
+ "contacts"
+ ]
+ assert [c["display_name"] for c in listed] == ["Anna Kowalska"]
+ contact = (await ctx.web.get(f"{CON}/contacts/{listed[0]['id']}")).json()
+ assert contact["org"] == "Imaginary Pixels" and contact["department"] == "Compositing"
+ assert (
+ contact["emails"][0]["value"] == "anna@ip.example"
+ and contact["phones"][0]["type"] == "mobile"
+ )
+ assert [g["name"] for g in contact["groups"]] == ["Clients"]
+ got = await ctx.dav.get(url)
+ assert got.headers["etag"] == put.headers["etag"] and "X-ABUID:ABC123" in got.text # verbatim
+ token1, body = await _sync(ctx, token0)
+ assert "ABC123.vcf" in body
+
+ # The web app edits it: the phone gets a regenerated card.
+ edit = dict(contact, display_name="Anna K.", group_ids=[g["id"] for g in contact["groups"]])
+ assert (await ctx.web.put(f"{CON}/contacts/{contact['id']}", json=edit)).status_code == 200
+ token2, body = await _sync(ctx, token1)
+ assert "ABC123.vcf" in body
+ got = await ctx.dav.get(url)
+ assert "FN:Anna K." in got.text and "CATEGORIES:Clients" in got.text
+
+ # Deleting the group changes the contact's CATEGORIES.
+ gid = contact["groups"][0]["id"]
+ assert (await ctx.web.delete(f"{CON}/groups/{gid}")).status_code == 200
+ token3, body = await _sync(ctx, token2)
+ assert "ABC123.vcf" in body and "CATEGORIES" not in (await ctx.dav.get(url)).text
+
+ # Multiget, as phones fetch changed cards.
+ multiget = (
+ ''
+ f"{url}{ctx.home}{ctx.book_id}/missing.vcf"
+ ""
+ )
+ r = await ctx.dav.request("REPORT", f"{ctx.home}{ctx.book_id}/", content=multiget)
+ assert r.status_code == 207 and "BEGIN:VCARD" in r.text and "404" in r.text
+
+ # Deleted in the web app: gone for the phone.
+ assert (await ctx.web.delete(f"{CON}/contacts/{contact['id']}")).status_code == 200
+ _, body = await _sync(ctx, token3)
+ assert "ABC123.vcf" in body and "404" in body
diff --git a/docker/nginx-app.conf b/docker/nginx-app.conf
index ee492fd..19e474b 100644
--- a/docker/nginx-app.conf
+++ b/docker/nginx-app.conf
@@ -47,6 +47,26 @@ location /api/ {
proxy_buffering off;
}
+# ---- CalDAV / CardDAV (SPEC Appendix E) -----------------------------------
+# Phones, Thunderbird and macOS sync calendars and contacts here, signing in
+# with an app password. The well-known paths are how clients find the server
+# from just a host name.
+location /dav/ {
+ proxy_pass http://webmail_api;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $fwd_proto;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_read_timeout 300s;
+}
+location ~ ^/\.well-known/(caldav|carddav)$ {
+ proxy_pass http://webmail_api;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Proto $fwd_proto;
+}
+
# ---- static -------------------------------------------------------------
# Vite emits content-hashed filenames into /assets, so they are immutable.
location /assets/ {
diff --git a/docs/nas-install.md b/docs/nas-install.md
index 3fff490..359b6e7 100644
--- a/docs/nas-install.md
+++ b/docs/nas-install.md
@@ -596,6 +596,24 @@ then also needs `https://ultimo.email/.well-known/assetlinks.json` naming the
Android package and its signing-certificate fingerprint — served by nginx
like any other static file.
+### 5.4 Calendars and contacts in other apps (CalDAV / CardDAV)
+
+Ultimo serves each user's calendars and address books to standard apps, which
+sync two-way with the web app: iPhone/iPad and macOS Calendar & Contacts,
+Thunderbird, Android through DAVx⁵, GNOME/Evolution.
+
+- **Server:** `https://ultimo.email/dav/` — most apps also find it from just
+ `ultimo.email` (`/.well-known/caldav`, `/.well-known/carddav`).
+- **User name:** the full Ultimo address. **Password:** an **app password**
+ (Settings → Security → App passwords) — the account password is refused, so
+ 2FA cannot be bypassed and a lost device is one revocation away. Settings shows
+ the exact server and user name to type.
+- nginx passes `/dav/` and the two well-known paths to the API (in
+ `docker/nginx-app.conf`). A reverse proxy in front (QNAP, DSM) must pass them
+ too, with the `Authorization` header — the default.
+- HTTPS is required by iOS/macOS for these accounts.
+- Repeated wrong passwords are throttled per address and per IP (15 minutes).
+
---
## 6. Resource usage
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index bdd3a34..82cda72 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -2,6 +2,7 @@
// (SPEC §10.3) and includes cookies for the session (SPEC §6.1).
import type {
+ AppPassword,
AccountDetail,
AdminMail,
AdminSettings,
@@ -242,6 +243,14 @@ export const api = {
body: JSON.stringify({ password }),
}),
sessions: () => apiFetch("/auth/sessions"),
+ appPasswords: () => apiFetch("/auth/app-passwords"),
+ createAppPassword: (name: string, password: string) =>
+ apiFetch("/auth/app-passwords", {
+ method: "POST",
+ body: JSON.stringify({ name, password }),
+ }),
+ revokeAppPassword: (id: string) =>
+ apiFetch<{ message: string }>(`/auth/app-passwords/${id}`, { method: "DELETE" }),
revokeSession: (sid: string) =>
apiFetch<{ status: string }>(`/auth/sessions/${sid}`, { method: "DELETE" }),
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index e21dece..832a110 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -345,3 +345,11 @@ export interface RemoteDraftOut {
snippet: string;
updated_at: string;
}
+
+/** A password for one mail app (Settings → Security → App passwords). */
+export interface AppPassword {
+ id: string;
+ name: string;
+ created_at: string;
+ last_used_at: string | null;
+}
diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx
index 9089d25..815d3cb 100644
--- a/frontend/src/routes/settings/Settings.tsx
+++ b/frontend/src/routes/settings/Settings.tsx
@@ -509,6 +509,189 @@ function OfflineAccessSetting() {
);
}
+/**
+ * Settings → Security: a password for each mail app (Thunderbird, a phone's
+ * Mail app) — they sign in over IMAP/SMTP, where no 2FA code can be asked for.
+ * The password is shown once, when made; each can be revoked on its own.
+ */
+function AppPasswordsSetting() {
+ const t = useT();
+ const handle = useMe().data?.handle ?? "";
+ const queryClient = useQueryClient();
+ const list = useQuery({ queryKey: ["app-passwords"], queryFn: api.appPasswords });
+ const [creating, setCreating] = useState(false);
+ const [name, setName] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [made, setMade] = useState<{ name: string; secret: string } | null>(null);
+ const [copied, setCopied] = useState(false);
+
+ const create = useMutation({
+ mutationFn: () => api.createAppPassword(name.trim(), password),
+ onSuccess: (out) => {
+ setMade({ name: out.name, secret: out.app_password });
+ setCreating(false);
+ setName("");
+ setPassword("");
+ setError("");
+ void queryClient.invalidateQueries({ queryKey: ["app-passwords"] });
+ },
+ onError: (e) => setError(e instanceof ApiError ? e.message : String(e)),
+ });
+ const revoke = useMutation({
+ mutationFn: (id: string) => api.revokeAppPassword(id),
+ onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["app-passwords"] }),
+ });
+
+ const copy = async () => {
+ if (!made) return;
+ try {
+ await navigator.clipboard.writeText(made.secret);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 2000);
+ } catch {
+ /* the text is on screen to copy by hand */
+ }
+ };
+ const date = (iso: string) => new Date(iso).toLocaleDateString(getDateLocale(), { day: "numeric", month: "short", year: "numeric" });
+
+ return (
+
+
{t("appPasswords.title")}
+
{t("appPasswords.help")}
+ {/* What to type into a calendar / contacts app (CalDAV / CardDAV, SPEC App. E). */}
+
+
{t("appPasswords.connectTitle")}
+
+
{t("appPasswords.connectServer")}
+
{`${window.location.origin}/dav/`}
+
{t("appPasswords.connectUser")}
+
{handle}
+
{t("appPasswords.connectPassword")}
+
{t("appPasswords.connectPasswordValue")}
+
+
{t("appPasswords.connectHint")}
+
+
+ {made && (
+
+
{t("appPasswords.madeFor", { name: made.name })}
+
+ {/* Shown in groups of four; copied (and entered) without spaces. */}
+
+ {made.secret.match(/.{1,4}/g)?.map((group, i) => (
+ // Spacing only, no characters: copying by hand gives the bare letters.
+
+ {group}
+
+ ))}
+
+
+