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:
@@ -874,6 +874,11 @@ CREATE VIEW mail_principals AS
|
||||
### D.5 External mail clients and 2FA
|
||||
If phones/desktop clients may use the mailbox over IMAP/SMTP, a password alone would bypass TOTP. Rule: users with 2FA enabled must use **app passwords** for IMAP/SMTP — a `user_app_passwords` table (argon2id hashes) added to the directory view, plus a Settings screen to create/revoke them. Stalwart's login rate limits / auto-ban on. If external clients are not wanted, IMAP/SMTP submission is exposed only on the Docker network and this section shrinks to nothing.
|
||||
|
||||
**App passwords — built (v1.3.37, ahead of D5):** table `user_app_passwords` (migration 0024: `id, user_id, name, password_hash, created_at, last_used_at`), `app/identity/app_passwords.py`, routes `GET/POST /api/auth/app-passwords`, `DELETE /api/auth/app-passwords/{id}`, Settings → Security → App passwords.
|
||||
- 16 random lowercase letters (~75 bits), shown once in groups of four, copied and entered **without spaces**: the hash is of the bare letters, because the mail server compares exactly what an app sends.
|
||||
- Creating one takes the account password; at most 25 per user; each is revoked on its own; a password reset revokes them all (as Google does). The webmail's own sign-in never accepts one.
|
||||
- Still for D2/D5: add the hashes to the directory's secrets query (a user's secrets = `users.password_hash` unless 2FA is on, plus every `user_app_passwords.password_hash`), and set `last_used_at` from the mail server's successful sign-ins.
|
||||
|
||||
### D.6 Storing mail once
|
||||
Today the indexer keeps every message in full (raw `.eml` blob + sanitized body in `body_parts` + search index), so external accounts are already a second copy, and with Stalwart both copies would sit on the same disk. Ideas, best first:
|
||||
|
||||
@@ -912,5 +917,32 @@ Today the indexer keeps every message in full (raw `.eml` blob + sanitized body
|
||||
3. May external mail clients (IMAP/SMTP) use these mailboxes?
|
||||
4. Ship D1 ahead of the rest?
|
||||
|
||||
## Appendix E — CalDAV / CardDAV server (phones, Thunderbird, macOS)
|
||||
|
||||
Ultimo serves the calendars and address books it already holds to standard clients — iPhone/iPad and macOS Calendar & Contacts, Thunderbird, Android via DAVx5, GNOME/Evolution — so they sync two-way with the web app. No second store: clients read and write the calendar/contacts plugins' own tables. (Chosen over Stalwart's DAV, which would make Stalwart the store and the plugins its clients, §D.)
|
||||
|
||||
### E.1 Shape
|
||||
- **Core `app/dav/`** — the protocol: routes, Basic sign-in, discovery, principal, XML (PROPFIND / PROPPATCH / REPORT multistatus), ETag preconditions, sync tokens (RFC 6578). No knowledge of events or contacts.
|
||||
- **Providers in the plugins** — `Plugin(dav=...)`: the calendar plugin serves calendar collections (iCalendar, RFC 4791), the contacts plugin address books (vCard, RFC 6352). Interface: list collections; list resources (name + ETag); get; put (If-Match / If-None-Match); delete; changes since a sync token; create / update / delete a collection; calendar time-range query. Each runs in the user's RLS session (`sdk.session`).
|
||||
- **URLs** — `/.well-known/caldav`, `/.well-known/carddav` → 301 `/dav/`; principal `/dav/principals/{user_id}/`; homes `/dav/calendars/{user_id}/`, `/dav/addressbooks/{user_id}/`; a collection `…/{collection_id}/`; a resource `…/{name}.ics|.vcf`. nginx proxies `/dav/` and the two well-known paths to the API.
|
||||
|
||||
### E.2 Sign-in
|
||||
HTTP Basic over HTTPS: the handle (full address or local part) and an **app password** (§D.5) — never the account password, so 2FA cannot be bypassed and each device can be revoked. A successful check is cached briefly (keyed by a hash of the password, invalidated when app passwords change) so a sync's many requests don't each cost an argon2id verify; failures are rate-limited per address and IP. `last_used_at` is set (at most every few minutes).
|
||||
|
||||
### E.3 Calendars
|
||||
- A **resource** is one iCalendar object per UID: the series plus its moved/cancelled occurrences (`RECURRENCE-ID`). Name: what the client PUT it as, else derived from the UID.
|
||||
- **Fidelity:** what a client PUTs is kept verbatim and served back unchanged while the event's modelled fields are still what it said; once the web app (or iMIP, or an upstream sync) changes them, the object is regenerated from the fields — SUMMARY, DESCRIPTION, LOCATION, DTSTART/DTEND with TZID and VTIMEZONE, all-day dates, RRULE, EXDATE, overrides, STATUS, TRANSP, ORGANIZER, ATTENDEEs with PARTSTAT, VALARMs from reminders, COLOR, the video link.
|
||||
- **Writes** go through the same columns the web app uses, so reminders, invitations and upstream CalDAV write-back (the `dav_dirty` trigger, tombstones on synced calendars) behave identically. Subscribed (ICS) and read-only calendars are served read-only.
|
||||
- **Change tracking** by triggers on the plugin tables (any write path counts) into a per-calendar change log; a calendar's sync token / CTag is its latest change number; deletions are reported by name. Old log entries are pruned; a client holding a pruned token gets `valid-sync-token` and resyncs.
|
||||
- REPORTs: `calendar-query` (time-range on VEVENT), `calendar-multiget`, `sync-collection`. MKCALENDAR and PROPPATCH (name, colour) create / restyle local calendars; DELETE of a collection deletes a local calendar.
|
||||
|
||||
### E.4 Contacts
|
||||
Address books = `plg_contacts_books`; resources = contacts as vCard (3.0 served; 3.0 and 4.0 accepted): FN, N, NICKNAME, EMAIL (type, pref), TEL, ADR, URL, ORG/department, TITLE, BDAY, NOTE, CATEGORIES ↔ groups, the VIP star as an `X-` property. Same fidelity rule (`raw_vcard` kept until the web app edits), same change tracking. REPORTs: `addressbook-query` (basic), `addressbook-multiget`, `sync-collection`.
|
||||
|
||||
### E.5 Phases
|
||||
- **E1** core + discovery + sign-in + calendars read/write (tested with a CalDAV client library);
|
||||
- **E2** vCard read/write + address books;
|
||||
- **E3** deployment (nginx, docs: how to add the account on each client), then checks with real devices.
|
||||
|
||||
---
|
||||
*End of specification. Ambiguities: prefer the simplest behavior consistent with the invariants in §1, leave a `TODO(spec)` comment, and list open questions in `docs/open-questions.md` rather than guessing silently.*
|
||||
|
||||
Reference in New Issue
Block a user