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
+17
View File
@@ -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.