diff --git a/CHANGELOG.md b/CHANGELOG.md index bd89aeb..ad1cec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ What changed in each RustBox release, written for the people who use and run it. Technical details are in the [documentation](docs/). +## 0.2.30 + +- Optional CalDAV and CardDAV server. With `[dav] enabled = true` every account gets a calendar and an address book at `/dav/` that phones, Thunderbird, DAVx⁵, Apple Calendar/Contacts or an ActiveSync gateway such as ActivePush can sync. It uses the same events and contacts as the webmail, so a change made in either place appears in the other. Sign in with your mail address and password. + ## 0.2.29 - An open message thread now updates automatically when a new message for that thread arrives via realtime events; reopening the thread is no longer required. diff --git a/Cargo.lock b/Cargo.lock index 49845f1..2e8ed5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,6 +547,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2899,6 +2909,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -3506,7 +3534,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.29" +version = "0.2.30" dependencies = [ "aes-gcm", "async-imap", @@ -3516,6 +3544,8 @@ dependencies = [ "axum-extra", "base64 0.22.1", "bytes", + "chrono", + "chrono-tz", "dashmap", "encoding_rs", "figment", @@ -3527,7 +3557,9 @@ dependencies = [ "lettre", "mail-parser", "object_store", + "percent-encoding", "pgp", + "quick-xml", "rand 0.8.8", "rand 0.9.5", "redis", @@ -3956,6 +3988,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "sketches-ddsketch" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 603b383..970a4c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.29" +version = "0.2.30" edition = "2024" rust-version = "1.99.1" license = "AGPL-3.0-only" @@ -53,6 +53,12 @@ object_store = { version = "0.14.2", features = ["aws"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } bytes = "1" +# CalDAV / CardDAV server ([dav] section) +quick-xml = "0.41" +percent-encoding = "2" +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +chrono-tz = "0.10" + [dev-dependencies] http-body-util = "0.1" tempfile = "3" diff --git a/README.md b/README.md index 7a218b3..ea769fc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.29 +# RustBox 0.2.30 **Fast, self-hosted webmail written in Rust.** RustBox connects to any IMAP/SMTP server and ships as one executable with the whole interface built in, so there is no Node.js, PHP or separate frontend to deploy. @@ -9,7 +9,7 @@ - Three-pane mail client with folder tree, threads, bulk actions, drag and drop, snooze, undo send and undo move. - HTML, plain-text and Markdown composer with inline images, drafts, identities, signatures and OpenPGP. - Server-side filters (Sieve) with a visual editor, exceptions, templates and **Apply now** for mail that is already in a folder. -- Calendar with reminders and ICS import/export, contacts with groups and vCard. +- Calendar with reminders and ICS import/export, contacts with groups and vCard; optional CalDAV/CardDAV server to sync both with phones and desktop clients. - Realtime new-mail updates and desktop notifications. - 43 interface styles, from Gmail- and Outlook-inspired to Dracula and Windows XP, plus a fully adjustable custom style, light/dark/time-of-day themes and a mobile layout. - SQLite, MySQL or PostgreSQL; local disk, shared filesystem or S3 storage; built-in search or OpenSearch; optional cluster mode. diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 380e295..408cbd5 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,4 +1,7 @@ -RustBox 0.2.29 release manifest +RustBox 0.2.30 release manifest + +0.2.30 release changes: +- optional built-in CalDAV / CardDAV server (`[dav] enabled = true`): one calendar and one address book per account at `/dav/`, backed by the webmail calendar and contacts tables; PROPFIND, REPORT (calendar-query, calendar-multiget, addressbook-query, addressbook-multiget), GET/HEAD, PUT, DELETE, ETag/If-Match/If-None-Match, CTag (`getctag`), RFC 6764 `/.well-known/caldav|carddav`; HTTP Basic auth against IMAP with a short-lived login cache and the webmail brute-force guard; uploaded iCalendar/vCard objects are stored verbatim and webmail edits are patched into them property by property (migration V055); see `docs/CALDAV_CARDDAV.md` 0.2.29 release changes: - open message thread refreshes automatically after realtime NewMessages events, without requiring the user to reopen it @@ -122,7 +125,8 @@ Database/schema: - databases older than V052 must be upgraded with a V052-capable RustBox release (0.2.22) before using this build - V053 adds `display_preferences.skin_depth` (default `standard`) and `display_preferences.skin_gradients` (default off) to existing V052 tenants - V054 adds `theme_light_from`, `theme_dark_from`, `account_color`, `calendar_events.reminder_extra_minutes` and the calendar default reminders to existing V053 tenants -- next migration number: V055 +- V055 adds `dav_name` / `dav_data` to `calendar_events` and `contacts` (CalDAV/CardDAV resource name and stored object) and makes `idx_contacts_email` non-unique (an address book may hold several cards sharing one address) +- next migration number: V056 Docker examples: - docker/compose-single-sqlite.yml @@ -132,14 +136,14 @@ Docker examples: Operations/docs: - standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets -- README.md documents 0.2.29 configuration +- README.md documents 0.2.30 configuration - docker/README.md documents Compose variants - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending Build/release metadata: - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts -- Cargo.toml version = 0.2.29 +- Cargo.toml version = 0.2.30 - rust-toolchain.toml pins Rust 1.99.1 - Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing - CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 8ea3aff..dcc9fc6 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,4 +1,7 @@ -RustBox 0.2.29 - final source validation +RustBox 0.2.30 - final source validation + +0.2.30 scope: +- optional CalDAV/CardDAV server (`[dav]`, off by default) on the webmail calendar and contacts tables, migration V055; exercised end to end with the ActivePush client request set and the python-caldav client against a live server with a stub IMAP login 0.2.29 scope: - realtime refresh of the currently open message thread when NewMessages arrives @@ -67,11 +70,11 @@ RustBox 0.2.29 - final source validation - PDF attachment preview uses the authenticated inline URL for Safari/iPhone compatibility - Composing shows only compact attachment/message size-limit values on one line - 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact -- V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients`; V054 adds the theme schedule and account colour; V055 is next -- version metadata identifies 0.2.29 +- V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients`; V054 adds the theme schedule and account colour; V055 adds the CalDAV/CardDAV columns (`dav_name`, `dav_data`) and a non-unique contacts e-mail index; V056 is next +- version metadata identifies 0.2.30 Completed in this environment: -- offline release validator: scripts/validate-release-static.py (458/458 checks passed) +- offline release validator: scripts/validate-release-static.py (467/467 checks passed) - browser JavaScript assembled in manifest order passes `node --check` - mailbox thread grouping smoke test covers a three-message References chain, expand/collapse behavior and flat-mode fallback - reply-recipient helper smoke test covers own-account, alias and normal incoming-message replies @@ -79,7 +82,7 @@ Completed in this environment: - all JSON files parse successfully - Python helper scripts compile successfully - shell scripts pass `bash -n` -- release metadata consistently identifies 0.2.29 +- release metadata consistently identifies 0.2.30 - V052 baseline consolidation plus the V053 skin-depth migration are validated offline - 0.2.25 filter UI (apply now, run now, always move from sender), context submenus and preset editing exercised in Playwright against a mocked API; the 0.2.24 styles rendered at standard and immersive depth with gradients on and off; earlier: all 37 interface styles rendered in light and dark at the immersive depth against a mocked API (Playwright), with no page errors diff --git a/config.example.toml b/config.example.toml index 648bc01..80608c6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -215,6 +215,27 @@ worker_max_attempts = 8 # local filesystem storage in single-node mode. In cluster mode use S3-compatible # storage or a genuinely shared POSIX filesystem (NFS/CephFS/GlusterFS). +# Built-in CalDAV / CardDAV server. Disabled by default. +# Every account gets one calendar and one address book, backed by the same tables +# as the webmail calendar and contacts: +# https://mail.example.com/dav/calendars//default/ +# https://mail.example.com/dav/addressbooks//default/ +# Clients may also start from https://mail.example.com/dav/ or rely on +# /.well-known/caldav and /.well-known/carddav. Authentication is HTTP Basic with +# the IMAP login and password (checked against imap_host, brute-force protected by +# [login_rate_limit]). Serve RustBox over HTTPS when this is enabled. +# Environment overrides: DAV__ENABLED=true, DAV__AUTH_CACHE_SECS=300, ... +[dav] +enabled = false +# Seconds a successful Basic login is remembered before IMAP is asked again +# (DAV clients authenticate on every request). 0 disables the cache. +auth_cache_secs = 300 +# Largest accepted calendar object / vCard (bytes). +max_resource_size = 8388608 +# Display names of the collections. +calendar_name = "Calendar" +addressbook_name = "Contacts" + # Administrator-only system diagnostics endpoint. Disabled by default. # When enabled it exposes a redacted runtime/configuration report plus live probes # for SQL, session persistence (database/Redis), blob storage (filesystem/S3), diff --git a/docs/CALDAV_CARDDAV.md b/docs/CALDAV_CARDDAV.md new file mode 100644 index 0000000..9bb2702 --- /dev/null +++ b/docs/CALDAV_CARDDAV.md @@ -0,0 +1,147 @@ +# CalDAV / CardDAV server + +RustBox can serve each account's calendar and contacts over CalDAV (RFC 4791) and +CardDAV (RFC 6352). The DAV view and the webmail use the same tables, so an event +created on a phone appears in the webmail calendar and a contact edited in the +webmail appears on the phone after its next sync. + +The server is **off by default**. + +## Enabling + +```toml +[dav] +enabled = true +auth_cache_secs = 300 # remember a successful login for 5 minutes (0 = always ask IMAP) +max_resource_size = 8388608 # largest accepted event / vCard in bytes +calendar_name = "Calendar" +addressbook_name = "Contacts" +``` + +Environment overrides use the usual form: `DAV__ENABLED=true`, `DAV__AUTH_CACHE_SECS=60`. + +Run RustBox behind HTTPS when DAV is enabled: clients send the mail password with +every request (HTTP Basic). + +## URLs + +Below `/dav` (with the default base path: `https://mail.example.com/dav/`): + +| Resource | Path | +| :--- | :--- | +| Service root (for discovery) | `/dav/` | +| Principal | `/dav/principals//` | +| Calendar home | `/dav/calendars//` | +| Calendar | `/dav/calendars//default/` | +| Address book home | `/dav/addressbooks//` | +| Address book | `/dav/addressbooks//default/` | + +`/.well-known/caldav` and `/.well-known/carddav` redirect to `/dav/` +(RFC 6764). They are registered at the host root even when RustBox runs under a +base path; a reverse proxy that only forwards the base path must forward these two +paths as well. + +`` is the IMAP login the client authenticates with (normally the e-mail +address). A client may only open its own collections; other logins get `403`. + +## Authentication + +HTTP Basic with the IMAP login and password. RustBox checks them against the +configured `imap_host` exactly like the webmail login and protects the endpoint +with the same brute-force guard (`[login_rate_limit]`). A successful login is cached +in memory for `auth_cache_secs`, keyed by a hash of login and password, so changing +the mail password takes effect for DAV within that time. The tenant database is +created on the first DAV login, so DAV works for accounts that never opened the +webmail. + +## Supported protocol + +- `OPTIONS` (no authentication), `PROPFIND` (Depth 0/1), `PROPPATCH` (answered with + `403` per property: collection properties are fixed), `GET`/`HEAD`, `PUT`, `DELETE`. +- `REPORT`: `calendar-query` (VEVENT with optional `time-range`), + `calendar-multiget`, `addressbook-query` (prop-filter / text-match, `anyof`/`allof`, + `limit`), `addressbook-multiget`. Other reports (for example `sync-collection`) + return `403 DAV:supported-report`; `sync-token` is not advertised, so clients fall + back to CTag polling. +- Strong ETags (hash of the served object) with `If-Match` / `If-None-Match`, + and a collection CTag (`CS:getctag`) that changes on every change, including + webmail edits and deletions. +- One calendar (VEVENT only; `supported-calendar-component-set` lists VEVENT) and + one address book (vCard 3.0 and 4.0) per account. `MKCALENDAR` / `MKCOL` return + `403`. Tasks (VTODO) are not supported. +- RFC 4791 UID uniqueness: a second resource with an existing UID is refused with + `CALDAV:no-uid-conflict` — except that an event created in the webmail (for + example from an e-mail invitation) is adopted, so accepting an invitation on a + phone does not duplicate it. + +## How data is mapped + +Objects uploaded by a client are stored verbatim (`dav_data`) under the client's +resource name (`dav_name`) and decoded into the webmail columns: + +| iCalendar | Webmail field | +| :--- | :--- | +| `SUMMARY`, `DESCRIPTION`, `LOCATION` | title, description, location | +| `DTSTART` / `DTEND` / `DURATION` | start / end (times with `TZID` are converted to UTC; dates make an all-day event) | +| `RRULE` | recurrence rule | +| `ATTENDEE`, `ORGANIZER` | attendees, organizer | +| `STATUS`, `URL`, `COLOR` | status, meeting link, colour | +| first two `VALARM` triggers before the start | reminders | + +Only the master component is decoded; recurrence overrides (`RECURRENCE-ID`), +`EXDATE`, `VTIMEZONE` and unknown properties stay in the stored object and are +served back unchanged. + +| vCard | Webmail field | +| :--- | :--- | +| `FN` (or `N`) | name | +| `EMAIL` (preferred first) | e-mail and further addresses | +| `TEL`, `ADR` | phone numbers, postal addresses | +| `ORG`, `TITLE`, `NOTE`, `URL`, `BDAY` | company, job title, notes, website, birthday | +| embedded `PHOTO` (JPEG/PNG/GIF/WebP up to 3 MB) | contact photo | + +Cards without an e-mail address are accepted. Since migration V055 several +contacts may share an e-mail address. + +When an event or contact that came from a DAV client is edited in the webmail, +only the properties whose value actually changed are rewritten in the stored +object (plus `DTSTAMP`/`LAST-MODIFIED`/`SEQUENCE` or `REV`); time zones, exceptions, +alarms the webmail does not model and vendor properties are kept. Events and +contacts created in the webmail are served as `.ics` / `.vcf` with a +generated object (iCalendar 2.0 / vCard 3.0). + +## Clients + +- **ActivePush** (Exchange ActiveSync gateway): no code changes are needed, only + the backend URLs. Its default paths already match RustBox: + + ```toml + [caldav] + enabled = true + base_url = "https://mail.example.com" + path = "/dav/calendars/%u/" + + [carddav] + enabled = true + base_url = "https://mail.example.com" + path = "/dav/addressbooks/%u/" + ``` + + With a RustBox `base_path` (for example `/mail`) prefix the paths: + `/mail/dav/calendars/%u/`. ActivePush authenticates with the same login and + password the phone uses, which must be the IMAP login RustBox accepts. Tasks stay + disabled because the calendar advertises VEVENT only; creating extra calendar or + contact folders from the phone is refused. +- **DAVx⁵, Thunderbird, Apple Calendar/Contacts, Evolution**: use the server URL + `https://mail.example.com/` (discovery through `.well-known`) or + `https://mail.example.com/dav/`, and the mail login and password. + +## Limits + +- Recurring events are not expanded server side: a `time-range` query returns a + recurring event whenever its first occurrence starts before the end of the range. +- ETags and the CTag are computed from the objects on each request; this is cheap + for personal calendars and address books (thousands of entries) but means a CTag + poll reads the whole collection. +- Contact groups are not exported as group vCards. +- Contacts photos added in the webmail are not included in generated vCards. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 399a92e..707a2ee 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -86,6 +86,7 @@ RustBox manages server-side filters over ManageSieve, the standard used by Dovec - Events with all-day mode, location, attendees, status, colour and a meeting link. - Two reminders per event (for example 15 minutes and one day before), with default reminders for new events. Reminders for all-day events fire at the start of the working day. - ICS import and export, including importing an invitation attachment. +- Optional CalDAV/CardDAV server (`[dav]`): the same calendar and contacts sync with phones, Thunderbird, DAVx⁵, Apple Calendar/Contacts or an ActiveSync gateway such as ActivePush. See [CALDAV_CARDDAV.md](CALDAV_CARDDAV.md). ![Calendar](screenshots/calendar.png) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index e7ffcc5..65b271e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,7 +1,13 @@ -# RustBox 0.2.29 - implementation status +# RustBox 0.2.30 - implementation status - Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification. -This file tracks the `0.2.29` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. +This file tracks the `0.2.30` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. + +## 0.2.30 CalDAV / CardDAV server + +- Optional (`[dav] enabled = true`): one calendar (VEVENT) and one address book per account under `/dav/`, RFC 6764 `.well-known` redirects, HTTP Basic auth against IMAP with a login cache and the login brute-force guard. +- PROPFIND, REPORT (calendar-query with time-range, calendar-multiget, addressbook-query, addressbook-multiget), GET/HEAD, PUT, DELETE, strong ETags with If-Match/If-None-Match, CTag. Not implemented: sync-collection, VTODO, MKCALENDAR/MKCOL, server-side recurrence expansion. +- Uploaded objects are stored verbatim (`dav_data`, migration V055) and webmail edits are patched into them per property. Details: `docs/CALDAV_CARDDAV.md`. ## 0.2.29 realtime open-thread refresh diff --git a/migrations/README.md b/migrations/README.md index f822ae4..64fa089 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -43,7 +43,18 @@ V054 adds three appearance columns to `display_preferences` and calendar reminde Fresh installations already contain the columns in `schema.sql`. Existing V053 tenants execute `V054__theme_schedule_account_color_and_reminders.sql`. -The next migration number is **V055**. +## V055 — CalDAV / CardDAV + +V055 prepares the calendar and contacts tables for the optional CalDAV/CardDAV server (`[dav]`): + +- `calendar_events.dav_name` / `contacts.dav_name` (nullable) — resource name chosen by the DAV client (e.g. `4f9e….ics`); rows created in the webmail keep `NULL` and are served as `.ics` / `.vcf`; +- `calendar_events.dav_data` / `contacts.dav_data` (nullable) — the iCalendar object / vCard exactly as uploaded, so time zones, recurrence exceptions, alarms and vendor properties survive; webmail edits are patched into it; +- `idx_contacts_email` becomes a regular (non-unique) index, because an address book may contain several cards with the same address (and cards without any address); +- new indexes `idx_calendar_events_dav_name` and `idx_contacts_dav_name`. + +Fresh installations already contain the columns in `schema.sql`. Existing V054 tenants execute `V055__caldav_carddav.sql`. + +The next migration number is **V056**. ## Migration rules diff --git a/migrations/mysql/V055__caldav_carddav.sql b/migrations/mysql/V055__caldav_carddav.sql new file mode 100644 index 0000000..edfd0ba --- /dev/null +++ b/migrations/mysql/V055__caldav_carddav.sql @@ -0,0 +1,15 @@ +ALTER TABLE {{p}}calendar_events + ADD COLUMN dav_name VARCHAR(255), + ADD COLUMN dav_data LONGTEXT; + +ALTER TABLE {{p}}contacts + ADD COLUMN dav_name VARCHAR(255), + ADD COLUMN dav_data LONGTEXT; + +DROP INDEX {{p}}idx_contacts_email ON {{p}}contacts; + +CREATE INDEX {{p}}idx_contacts_email ON {{p}}contacts (email); + +CREATE INDEX {{p}}idx_calendar_events_dav_name ON {{p}}calendar_events (dav_name); + +CREATE INDEX {{p}}idx_contacts_dav_name ON {{p}}contacts (dav_name); diff --git a/migrations/mysql/schema.sql b/migrations/mysql/schema.sql index b114188..f779052 100644 --- a/migrations/mysql/schema.sql +++ b/migrations/mysql/schema.sql @@ -1,7 +1,7 @@ -- RustBox 0.2.0 MySQL schema. -- One physical database contains global sessions plus isolated per-account tables. -- Application tables are materialized by replacing {{p}} with a collision-checked prefix. --- Squashed compatibility baseline: V052; current schema: V054. +-- Squashed compatibility baseline: V052; current schema: V055. -- RUSTBOX:GLOBAL-BEGIN CREATE TABLE IF NOT EXISTS rustbox_accounts ( @@ -59,6 +59,8 @@ CREATE TABLE {{p}}calendar_events ( color TEXT, reminder_minutes BIGINT, reminder_extra_minutes BIGINT, + dav_name VARCHAR(255), + dav_data LONGTEXT, created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -122,7 +124,9 @@ CREATE TABLE {{p}}contacts ( birthday TEXT NOT NULL DEFAULT (''), emails_json LONGTEXT NOT NULL DEFAULT ('[]'), phones_json LONGTEXT NOT NULL DEFAULT ('[]'), - addresses_json LONGTEXT NOT NULL DEFAULT ('[]') + addresses_json LONGTEXT NOT NULL DEFAULT ('[]'), + dav_name VARCHAR(255), + dav_data LONGTEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE {{p}}display_preferences ( @@ -461,9 +465,11 @@ ALTER TABLE {{p}}messages ADD CONSTRAINT {{p}}fk_messages_0 FOREIGN KEY (folder) CREATE INDEX {{p}}idx_calendar_events_source_uid ON {{p}}calendar_events (source_uid); CREATE INDEX {{p}}idx_calendar_events_time ON {{p}}calendar_events (start_time, end_time); +CREATE INDEX {{p}}idx_calendar_events_dav_name ON {{p}}calendar_events (dav_name); CREATE INDEX {{p}}idx_cgm_contact_id ON {{p}}contact_group_members (contact_id); CREATE INDEX {{p}}idx_contacts_contact_count ON {{p}}contacts (contact_count DESC); -CREATE UNIQUE INDEX {{p}}idx_contacts_email ON {{p}}contacts (email); +CREATE INDEX {{p}}idx_contacts_email ON {{p}}contacts (email); +CREATE INDEX {{p}}idx_contacts_dav_name ON {{p}}contacts (dav_name); CREATE INDEX {{p}}idx_contacts_last_contacted ON {{p}}contacts (last_contacted DESC); CREATE INDEX {{p}}idx_contacts_name ON {{p}}contacts (name); CREATE INDEX {{p}}idx_known_addresses_name ON {{p}}known_addresses (name); diff --git a/migrations/pgsql/V055__caldav_carddav.sql b/migrations/pgsql/V055__caldav_carddav.sql new file mode 100644 index 0000000..6a3097e --- /dev/null +++ b/migrations/pgsql/V055__caldav_carddav.sql @@ -0,0 +1,15 @@ +ALTER TABLE {{p}}calendar_events + ADD COLUMN dav_name TEXT, + ADD COLUMN dav_data TEXT; + +ALTER TABLE {{p}}contacts + ADD COLUMN dav_name TEXT, + ADD COLUMN dav_data TEXT; + +DROP INDEX IF EXISTS {{p}}idx_contacts_email; + +CREATE INDEX {{p}}idx_contacts_email ON {{p}}contacts (email); + +CREATE INDEX {{p}}idx_calendar_events_dav_name ON {{p}}calendar_events (dav_name); + +CREATE INDEX {{p}}idx_contacts_dav_name ON {{p}}contacts (dav_name); diff --git a/migrations/pgsql/schema.sql b/migrations/pgsql/schema.sql index a6ca569..ca7113a 100644 --- a/migrations/pgsql/schema.sql +++ b/migrations/pgsql/schema.sql @@ -1,7 +1,7 @@ -- RustBox 0.2.0 PostgreSQL schema. -- One physical database contains global sessions plus isolated per-account tables. -- Application tables are materialized by replacing {{p}} with a collision-checked prefix. --- Squashed compatibility baseline: V052; current schema: V054. +-- Squashed compatibility baseline: V052; current schema: V055. -- RUSTBOX:GLOBAL-BEGIN CREATE TABLE IF NOT EXISTS rustbox_accounts ( @@ -59,6 +59,8 @@ CREATE TABLE {{p}}calendar_events ( color TEXT, reminder_minutes BIGINT, reminder_extra_minutes BIGINT, + dav_name TEXT, + dav_data TEXT, created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) ); @@ -122,7 +124,9 @@ CREATE TABLE {{p}}contacts ( birthday TEXT NOT NULL DEFAULT '', emails_json TEXT NOT NULL DEFAULT '[]', phones_json TEXT NOT NULL DEFAULT '[]', - addresses_json TEXT NOT NULL DEFAULT '[]' + addresses_json TEXT NOT NULL DEFAULT '[]', + dav_name TEXT, + dav_data TEXT ); CREATE TABLE {{p}}display_preferences ( @@ -462,9 +466,11 @@ ALTER TABLE {{p}}messages ADD CONSTRAINT {{p}}fk_messages_0 FOREIGN KEY (folder) CREATE INDEX {{p}}idx_calendar_events_source_uid ON {{p}}calendar_events (source_uid); CREATE INDEX {{p}}idx_calendar_events_time ON {{p}}calendar_events (start_time, end_time); +CREATE INDEX {{p}}idx_calendar_events_dav_name ON {{p}}calendar_events (dav_name); CREATE INDEX {{p}}idx_cgm_contact_id ON {{p}}contact_group_members (contact_id); CREATE INDEX {{p}}idx_contacts_contact_count ON {{p}}contacts (contact_count DESC); -CREATE UNIQUE INDEX {{p}}idx_contacts_email ON {{p}}contacts (email); +CREATE INDEX {{p}}idx_contacts_email ON {{p}}contacts (email); +CREATE INDEX {{p}}idx_contacts_dav_name ON {{p}}contacts (dav_name); CREATE INDEX {{p}}idx_contacts_last_contacted ON {{p}}contacts (last_contacted DESC); CREATE INDEX {{p}}idx_contacts_name ON {{p}}contacts (name); CREATE INDEX {{p}}idx_known_addresses_name ON {{p}}known_addresses (name); diff --git a/migrations/sqlite/V055__caldav_carddav.sql b/migrations/sqlite/V055__caldav_carddav.sql new file mode 100644 index 0000000..cfb3eb6 --- /dev/null +++ b/migrations/sqlite/V055__caldav_carddav.sql @@ -0,0 +1,19 @@ +ALTER TABLE {{p}}calendar_events + ADD COLUMN dav_name TEXT; + +ALTER TABLE {{p}}calendar_events + ADD COLUMN dav_data TEXT; + +ALTER TABLE {{p}}contacts + ADD COLUMN dav_name TEXT; + +ALTER TABLE {{p}}contacts + ADD COLUMN dav_data TEXT; + +DROP INDEX IF EXISTS {{p}}idx_contacts_email; + +CREATE INDEX {{p}}idx_contacts_email ON {{p}}contacts(email); + +CREATE INDEX {{p}}idx_calendar_events_dav_name ON {{p}}calendar_events(dav_name); + +CREATE INDEX {{p}}idx_contacts_dav_name ON {{p}}contacts(dav_name); diff --git a/migrations/sqlite/schema.sql b/migrations/sqlite/schema.sql index 4119563..584e4d4 100644 --- a/migrations/sqlite/schema.sql +++ b/migrations/sqlite/schema.sql @@ -1,7 +1,7 @@ -- RustBox 0.2.0 SQLite schema. -- One physical database contains global sessions plus isolated per-account tables. -- Application tables are materialized from the tenant section by replacing {{p}}. --- Squashed compatibility baseline: V052; current schema: V054. +-- Squashed compatibility baseline: V052; current schema: V055. -- RUSTBOX:GLOBAL-BEGIN CREATE TABLE IF NOT EXISTS rustbox_accounts ( @@ -59,6 +59,8 @@ CREATE TABLE {{p}}calendar_events ( color TEXT, reminder_minutes INTEGER, reminder_extra_minutes INTEGER, + dav_name TEXT, + dav_data TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -117,7 +119,7 @@ CREATE TABLE {{p}}contacts ( source TEXT NOT NULL DEFAULT 'manual', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), - phone TEXT NOT NULL DEFAULT '', job_title TEXT NOT NULL DEFAULT '', address TEXT NOT NULL DEFAULT '', website TEXT NOT NULL DEFAULT '', birthday TEXT NOT NULL DEFAULT '', emails_json TEXT NOT NULL DEFAULT '[]', phones_json TEXT NOT NULL DEFAULT '[]', addresses_json TEXT NOT NULL DEFAULT '[]'); + phone TEXT NOT NULL DEFAULT '', job_title TEXT NOT NULL DEFAULT '', address TEXT NOT NULL DEFAULT '', website TEXT NOT NULL DEFAULT '', birthday TEXT NOT NULL DEFAULT '', emails_json TEXT NOT NULL DEFAULT '[]', phones_json TEXT NOT NULL DEFAULT '[]', addresses_json TEXT NOT NULL DEFAULT '[]', dav_name TEXT, dav_data TEXT); CREATE TABLE {{p}}display_preferences ( id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), @@ -359,12 +361,14 @@ CREATE TABLE {{p}}user_meta ( CREATE INDEX {{p}}idx_calendar_events_source_uid ON {{p}}calendar_events(source_uid); CREATE INDEX {{p}}idx_calendar_events_time ON {{p}}calendar_events(start_time, end_time); +CREATE INDEX {{p}}idx_calendar_events_dav_name ON {{p}}calendar_events(dav_name); CREATE INDEX {{p}}idx_cgm_contact_id ON {{p}}contact_group_members(contact_id); CREATE INDEX {{p}}idx_contacts_contact_count ON {{p}}contacts(contact_count DESC); -CREATE UNIQUE INDEX {{p}}idx_contacts_email ON {{p}}contacts(email); +CREATE INDEX {{p}}idx_contacts_email ON {{p}}contacts(email); +CREATE INDEX {{p}}idx_contacts_dav_name ON {{p}}contacts(dav_name); CREATE INDEX {{p}}idx_contacts_last_contacted ON {{p}}contacts(last_contacted DESC); diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index fec7f35..7e58689 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -1037,6 +1037,28 @@ try: conn.close() except Exception as exc: errors.append(f"SQLite V054 migration execution failed: {exc}") +for _, dialect in MIGRATION_DIALECTS: + dialect_schema = read(f"migrations/{dialect}/schema.sql") + require( + dialect_schema.count("dav_name") >= 4 and dialect_schema.count("dav_data") >= 2 + and "CREATE UNIQUE INDEX {{p}}idx_contacts_email" not in dialect_schema, + f"{dialect} fresh schema has the CalDAV/CardDAV columns and a non-unique contacts e-mail index", + ) +try: + conn = sqlite3.connect(":memory:") + conn.executescript("CREATE TABLE u_prod__calendar_events (id TEXT PRIMARY KEY); INSERT INTO u_prod__calendar_events(id) VALUES('e1'); CREATE TABLE u_prod__contacts (id TEXT PRIMARY KEY, email TEXT NOT NULL); CREATE UNIQUE INDEX u_prod__idx_contacts_email ON u_prod__contacts(email); INSERT INTO u_prod__contacts(id, email) VALUES('c1', 'a@example.com');") + conn.executescript(read("migrations/sqlite/V055__caldav_carddav.sql").replace("{{p}}", "u_prod__")) + require(conn.execute("SELECT dav_name, dav_data FROM u_prod__calendar_events WHERE id='e1'").fetchone() == (None, None), "SQLite V055 adds empty CalDAV columns to existing events") + conn.execute("INSERT INTO u_prod__contacts(id, email, dav_name) VALUES('c2', 'a@example.com', 'c2.vcf')") + require(conn.execute("SELECT COUNT(*) FROM u_prod__contacts WHERE email='a@example.com'").fetchone() == (2,), "SQLite V055 allows several contacts with one address") + conn.close() +except Exception as exc: + errors.append(f"SQLite V055 migration execution failed: {exc}") +require( + "pub dav: DavConfig" in read("src/config.rs") and "[dav]" in read("config.example.toml") + and "crate::dav::routes(&config)" in read("src/routes/mod.rs"), + "CalDAV/CardDAV server is configurable and routed only when enabled", +) require( '"schedule"' in display_prefs_rs and 'value="schedule"' in index_html and "function scheduledTheme(" in prefs_js and 'id="pref-account-color"' in index_html and 'html[data-account-color="true"] .appbar::before' in read("src/web/css/50-settings.css"), diff --git a/src/config.rs b/src/config.rs index 4a722d7..f3e59b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -130,6 +130,54 @@ impl Default for DiagnosticsEndpointConfig { } } +/// Built-in CalDAV / CardDAV server (`[dav]`). Off by default. +#[derive(Debug, Deserialize, Clone)] +pub struct DavConfig { + /// Serve `/dav/` (CalDAV + CardDAV) and `/.well-known/caldav|carddav`. + #[serde(default)] + pub enabled: bool, + /// How long a successful HTTP Basic login is remembered before the IMAP + /// password is checked again (DAV clients authenticate on every request). + #[serde(default = "default_dav_auth_cache_secs")] + pub auth_cache_secs: u64, + /// Largest accepted calendar object / vCard in bytes. + #[serde(default = "default_dav_max_resource_size")] + pub max_resource_size: usize, + /// Display names of the single calendar and address book of every account. + #[serde(default = "default_dav_calendar_name")] + pub calendar_name: String, + #[serde(default = "default_dav_addressbook_name")] + pub addressbook_name: String, +} + +impl Default for DavConfig { + fn default() -> Self { + Self { + enabled: false, + auth_cache_secs: default_dav_auth_cache_secs(), + max_resource_size: default_dav_max_resource_size(), + calendar_name: default_dav_calendar_name(), + addressbook_name: default_dav_addressbook_name(), + } + } +} + +fn default_dav_auth_cache_secs() -> u64 { + 300 +} + +fn default_dav_max_resource_size() -> usize { + 8 * 1024 * 1024 +} + +fn default_dav_calendar_name() -> String { + "Calendar".to_string() +} + +fn default_dav_addressbook_name() -> String { + "Contacts".to_string() +} + #[derive(Debug, Deserialize, Clone)] pub struct SearchConfig { #[serde(default = "default_search_backend")] @@ -395,6 +443,9 @@ pub struct AppConfig { #[serde(default)] pub diagnostics_endpoint: DiagnosticsEndpointConfig, + #[serde(default)] + pub dav: DavConfig, + #[serde(default)] pub login_rate_limit: LoginRateLimitConfig, @@ -773,6 +824,7 @@ impl Default for AppConfig { search: SearchConfig::default(), sync: SyncConfig::default(), diagnostics_endpoint: DiagnosticsEndpointConfig::default(), + dav: DavConfig::default(), login_rate_limit: LoginRateLimitConfig::default(), session_timeout_hours: default_session_timeout_hours(), remember_me_enabled: default_remember_me_enabled(), @@ -1073,6 +1125,16 @@ impl AppConfig { return Err("Redis sessions require REDIS_URL or REDIS_SENTINEL_NODES + REDIS_SENTINEL_MASTER".to_string()); } } + if self.dav.enabled { + if self.dav.max_resource_size < 1024 || self.dav.max_resource_size > 64 * 1024 * 1024 { + return Err( + "dav.max_resource_size must be between 1024 and 67108864 bytes".to_string(), + ); + } + if self.dav.auth_cache_secs > 86_400 { + return Err("dav.auth_cache_secs must be at most 86400".to_string()); + } + } if self.diagnostics_endpoint.enabled { let path = self.diagnostics_endpoint.path.trim(); if !path.starts_with("/api/system/") || path.contains('?') || path.contains('#') { diff --git a/src/dav/content.rs b/src/dav/content.rs new file mode 100644 index 0000000..9a336c9 --- /dev/null +++ b/src/dav/content.rs @@ -0,0 +1,282 @@ +//! Content-line handling shared by iCalendar (RFC 5545) and vCard (RFC 6350). + +/// One unfolded content line. +#[derive(Debug, Clone)] +pub struct Line { + /// Upper-case property name without a vCard group prefix (`item1.EMAIL` -> `EMAIL`). + pub name: String, + /// Upper-case parameter names with their raw (unquoted) values. + pub params: Vec<(String, Vec)>, + /// Raw value, still escaped. + pub value: String, +} + +impl Line { + pub fn param(&self, name: &str) -> Option<&str> { + self.params + .iter() + .find(|(key, _)| key == name) + .and_then(|(_, values)| values.first()) + .map(String::as_str) + } + + /// True if any value of parameter `name` equals `needle` (case-insensitive), + /// also inside comma separated lists such as `TYPE=INTERNET,PREF`. + pub fn param_has(&self, name: &str, needle: &str) -> bool { + self.params + .iter() + .filter(|(key, _)| key == name) + .flat_map(|(_, values)| values.iter()) + .flat_map(|value| value.split(',')) + .any(|value| value.trim().eq_ignore_ascii_case(needle)) + } + + pub fn text(&self) -> String { + unescape_text(&self.value) + } +} + +/// Split a document into unfolded logical lines (handles CRLF and bare LF). +pub fn unfold(text: &str) -> Vec { + let mut lines: Vec = Vec::new(); + for raw in text.split('\n') { + let raw = raw.strip_suffix('\r').unwrap_or(raw); + if let Some(rest) = raw.strip_prefix(' ').or_else(|| raw.strip_prefix('\t')) + && let Some(last) = lines.last_mut() + { + last.push_str(rest); + continue; + } + if raw.is_empty() { + continue; + } + lines.push(raw.to_string()); + } + lines +} + +/// Parse one unfolded content line. Returns `None` for lines without a `:`. +pub fn parse_line(line: &str) -> Option { + let bytes = line.as_bytes(); + let mut i = 0usize; + while i < bytes.len() && bytes[i] != b';' && bytes[i] != b':' { + i += 1; + } + if i >= bytes.len() { + return None; + } + let full_name = &line[..i]; + let name = full_name + .rsplit('.') + .next() + .unwrap_or(full_name) + .to_ascii_uppercase(); + if name.is_empty() { + return None; + } + let mut params = Vec::new(); + while i < bytes.len() && bytes[i] == b';' { + i += 1; + let key_start = i; + while i < bytes.len() && bytes[i] != b'=' && bytes[i] != b';' && bytes[i] != b':' { + i += 1; + } + let key = line[key_start..i].to_ascii_uppercase(); + let mut values = Vec::new(); + if i < bytes.len() && bytes[i] == b'=' { + i += 1; + loop { + if i < bytes.len() && bytes[i] == b'"' { + i += 1; + let start = i; + while i < bytes.len() && bytes[i] != b'"' { + i += 1; + } + values.push(line[start..i.min(bytes.len())].to_string()); + if i < bytes.len() { + i += 1; + } + } else { + let start = i; + while i < bytes.len() + && bytes[i] != b',' + && bytes[i] != b';' + && bytes[i] != b':' + { + i += 1; + } + values.push(line[start..i].to_string()); + } + if i < bytes.len() && bytes[i] == b',' { + i += 1; + continue; + } + break; + } + } else if !key.is_empty() { + // vCard 2.1 style bare parameter (e.g. `TEL;CELL:`), treat as TYPE. + params.push(("TYPE".to_string(), vec![key])); + continue; + } + if !key.is_empty() { + params.push((key, values)); + } + } + if i >= bytes.len() || bytes[i] != b':' { + return None; + } + Some(Line { + name, + params, + value: line[i + 1..].to_string(), + }) +} + +/// Unescape a TEXT value (`\n`, `\,`, `\;`, `\\`). +pub fn unescape_text(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some('n') | Some('N') => out.push('\n'), + Some(other) => out.push(other), + None => out.push('\\'), + } + } else { + out.push(ch); + } + } + out +} + +/// Escape a TEXT value. +pub fn escape_text(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 8); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + ';' => out.push_str("\\;"), + ',' => out.push_str("\\,"), + '\n' => out.push_str("\\n"), + '\r' => {} + c => out.push(c), + } + } + out +} + +/// Split a structured value (e.g. `N`, `ADR`) on unescaped `;`. +pub fn split_structured(value: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut escaped = false; + for ch in value.chars() { + if escaped { + current.push('\\'); + current.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == ';' { + parts.push(unescape_text(¤t)); + current.clear(); + } else { + current.push(ch); + } + } + if escaped { + current.push('\\'); + } + parts.push(unescape_text(¤t)); + parts +} + +/// Fold a logical line at 75 octets without splitting UTF-8 characters. +pub fn fold(line: &str) -> String { + if line.len() <= 75 { + return line.to_string(); + } + let mut out = String::with_capacity(line.len() + line.len() / 70 * 3); + let mut width = 0usize; + let mut limit = 75usize; + for ch in line.chars() { + let len = ch.len_utf8(); + if width + len > limit { + out.push_str("\r\n "); + width = 0; + limit = 74; + } + out.push(ch); + width += len; + } + out +} + +/// Join logical lines into a folded CRLF document. +pub fn join(lines: &[String]) -> String { + let mut out = String::new(); + for line in lines { + out.push_str(&fold(line)); + out.push_str("\r\n"); + } + out +} + +/// Name of a `BEGIN:`/`END:` line, upper-cased. +pub fn component_marker(line: &str) -> Option<(bool, String)> { + let upper = line.trim_end().to_ascii_uppercase(); + if let Some(name) = upper.strip_prefix("BEGIN:") { + Some((true, name.trim().to_string())) + } else { + upper + .strip_prefix("END:") + .map(|name| (false, name.trim().to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unfolds_and_parses_parameters() { + let lines = unfold( + "ATTENDEE;CN=\"Doe, Jane\";ROLE=REQ-PARTICIPANT:mailto:j\r\n ane@example.com\r\n", + ); + assert_eq!(lines.len(), 1); + let line = parse_line(&lines[0]).unwrap(); + assert_eq!(line.name, "ATTENDEE"); + assert_eq!(line.param("CN"), Some("Doe, Jane")); + assert_eq!(line.value, "mailto:jane@example.com"); + } + + #[test] + fn group_prefix_and_type_lists() { + let line = parse_line("item1.EMAIL;type=INTERNET,pref:a@b.c").unwrap(); + assert_eq!(line.name, "EMAIL"); + assert!(line.param_has("TYPE", "PREF")); + let bare = parse_line("TEL;CELL:+48 123").unwrap(); + assert!(bare.param_has("TYPE", "CELL")); + } + + #[test] + fn escapes_roundtrip() { + let text = "a;b,c\\d\ne"; + assert_eq!(unescape_text(&escape_text(text)), text); + assert_eq!( + split_structured("Doe;Jane\\;X;;"), + vec!["Doe", "Jane;X", "", ""] + ); + } + + #[test] + fn folds_long_utf8_lines() { + let line = format!("SUMMARY:{}", "zażółć gęślą jaźń ".repeat(10)); + let folded = fold(&line); + for part in folded.split("\r\n") { + assert!(part.len() <= 75); + } + assert_eq!(unfold(&folded).join(""), line); + } +} diff --git a/src/dav/ical.rs b/src/dav/ical.rs new file mode 100644 index 0000000..27bb789 --- /dev/null +++ b/src/dav/ical.rs @@ -0,0 +1,1089 @@ +//! Mapping between iCalendar objects (RFC 5545) and RustBox calendar rows. +//! +//! Objects uploaded over CalDAV are stored verbatim (`calendar_events.dav_data`) +//! and additionally decoded into the regular columns used by the webmail. When an +//! event is later edited in the webmail only the properties that actually changed +//! are rewritten in the stored object, so time zones, exceptions (`EXDATE`, +//! `RECURRENCE-ID` overrides), alarms and vendor properties survive. + +use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use chrono_tz::Tz; + +use super::content::{self, Line, escape_text}; +use crate::db::calendar::{CalendarEvent, CreateEvent}; + +pub const PRODID: &str = "-//RustBox//CalDAV//EN"; + +#[derive(Debug, PartialEq, Eq)] +pub enum IcalError { + Invalid(String), + /// Valid iCalendar, but not a VEVENT (e.g. VTODO); RustBox calendars hold events only. + UnsupportedComponent, +} + +/// A point in time as stored by RustBox. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum When { + Utc(DateTime), + Floating(NaiveDateTime), + Date(NaiveDate), +} + +impl When { + /// Canonical form used in the `start_time` / `end_time` columns. + pub fn canonical(&self) -> String { + match self { + When::Utc(dt) => dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + When::Floating(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(), + When::Date(d) => d.format("%Y-%m-%d").to_string(), + } + } + + /// `(parameters, value)` for a DTSTART/DTEND property. + fn ical(&self) -> (&'static str, String) { + match self { + When::Utc(dt) => ("", dt.format("%Y%m%dT%H%M%SZ").to_string()), + When::Floating(dt) => ("", dt.format("%Y%m%dT%H%M%S").to_string()), + When::Date(d) => (";VALUE=DATE", d.format("%Y%m%d").to_string()), + } + } + + /// Approximate absolute time used for range filtering (floating/date as UTC). + pub fn naive_utc(&self) -> NaiveDateTime { + match self { + When::Utc(dt) => dt.naive_utc(), + When::Floating(dt) => *dt, + When::Date(d) => d.and_hms_opt(0, 0, 0).unwrap_or_default(), + } + } + + fn add(&self, duration: Duration) -> When { + match self { + When::Utc(dt) => When::Utc(*dt + duration), + When::Floating(dt) => When::Floating(*dt + duration), + When::Date(d) => When::Date(*d + Duration::days(duration.num_days().max(1))), + } + } +} + +/// Parse a value from the `start_time` / `end_time` columns. Accepts the formats +/// the webmail produces (`toISOString()`, offsets, `YYYY-MM-DDTHH:MM`, dates). +pub fn parse_stored(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + if let Ok(dt) = DateTime::parse_from_rfc3339(value) { + return Some(When::Utc(dt.with_timezone(&Utc))); + } + for format in [ + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%d %H:%M", + ] { + if let Some(stripped) = value.strip_suffix('Z') + && let Ok(dt) = NaiveDateTime::parse_from_str(stripped, format) + { + return Some(When::Utc(Utc.from_utc_datetime(&dt))); + } + if let Ok(dt) = NaiveDateTime::parse_from_str(value, format) { + return Some(When::Floating(dt)); + } + } + if let Ok(d) = NaiveDate::parse_from_str(value, "%Y-%m-%d") { + return Some(When::Date(d)); + } + if let Ok(d) = NaiveDate::parse_from_str(value, "%Y%m%d") { + return Some(When::Date(d)); + } + parse_ical_value(value, None, false) +} + +fn canonical_stored(value: &str) -> String { + parse_stored(value) + .map(|w| w.canonical()) + .unwrap_or_else(|| value.trim().to_string()) +} + +/// Resolve a TZID to an IANA zone (also common Windows/Outlook names and +/// `/vendor/prefix/Europe/Warsaw` style identifiers). +pub fn resolve_tzid(tzid: &str) -> Option { + let tzid = tzid.trim().trim_matches('"'); + if let Ok(tz) = tzid.parse::() { + return Some(tz); + } + let windows = match tzid { + "Central European Standard Time" | "Central Europe Standard Time" => "Europe/Warsaw", + "W. Europe Standard Time" => "Europe/Berlin", + "Romance Standard Time" => "Europe/Paris", + "GMT Standard Time" => "Europe/London", + "Greenwich Standard Time" => "Atlantic/Reykjavik", + "E. Europe Standard Time" => "Europe/Chisinau", + "FLE Standard Time" => "Europe/Kiev", + "Russian Standard Time" => "Europe/Moscow", + "Eastern Standard Time" => "America/New_York", + "Central Standard Time" => "America/Chicago", + "Mountain Standard Time" => "America/Denver", + "Pacific Standard Time" => "America/Los_Angeles", + "UTC" | "Coordinated Universal Time" => "UTC", + _ => "", + }; + if !windows.is_empty() { + return windows.parse().ok(); + } + // `/mozilla.org/20050126_1/Europe/Warsaw`, `/Europe/Warsaw`, ... + let segments: Vec<&str> = tzid.split('/').filter(|s| !s.is_empty()).collect(); + for start in 1..segments.len() { + if let Ok(tz) = segments[start..].join("/").parse::() { + return Some(tz); + } + } + None +} + +/// Parse a DATE / DATE-TIME property value. +fn parse_ical_value(value: &str, tzid: Option<&str>, date_param: bool) -> Option { + let value = value.trim(); + if date_param || (value.len() == 8 && value.bytes().all(|b| b.is_ascii_digit())) { + return NaiveDate::parse_from_str(&value[..value.len().min(8)], "%Y%m%d") + .ok() + .map(When::Date); + } + if let Some(utc) = value.strip_suffix('Z') { + let dt = NaiveDateTime::parse_from_str(utc, "%Y%m%dT%H%M%S").ok()?; + return Some(When::Utc(Utc.from_utc_datetime(&dt))); + } + let dt = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S").ok()?; + if let Some(tz) = tzid.and_then(resolve_tzid) { + let local = tz.from_local_datetime(&dt).earliest().or_else(|| { + tz.from_local_datetime(&(dt + Duration::hours(1))) + .earliest() + })?; + return Some(When::Utc(local.with_timezone(&Utc))); + } + Some(When::Floating(dt)) +} + +fn parse_property_time(line: &Line) -> Option { + let date_param = line + .param("VALUE") + .is_some_and(|v| v.eq_ignore_ascii_case("DATE")); + parse_ical_value(&line.value, line.param("TZID"), date_param) +} + +/// Parse an RFC 5545 duration (`-P1DT2H`, `PT15M`, `P1W`) into seconds. +pub fn parse_duration(value: &str) -> Option { + let value = value.trim(); + let (negative, rest) = match value.as_bytes().first()? { + b'-' => (true, &value[1..]), + b'+' => (false, &value[1..]), + _ => (false, value), + }; + let rest = rest.strip_prefix('P').or_else(|| rest.strip_prefix('p'))?; + let mut total = 0i64; + let mut number = String::new(); + let mut in_time = false; + for ch in rest.chars() { + match ch.to_ascii_uppercase() { + 'T' => in_time = true, + d if d.is_ascii_digit() => number.push(d), + unit => { + let n: i64 = number.parse().ok()?; + number.clear(); + total += match (unit, in_time) { + ('W', false) => n * 7 * 86_400, + ('D', false) => n * 86_400, + ('H', true) => n * 3_600, + ('M', true) => n * 60, + ('S', true) => n, + _ => return None, + }; + } + } + } + if !number.is_empty() { + return None; + } + Some(if negative { -total } else { total }) +} + +/// The part of an event that RustBox understands, in canonical form. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EventFields { + pub title: String, + pub description: String, + pub location: String, + pub start: String, + pub end: String, + pub all_day: bool, + pub rrule: Option, + pub attendees: Vec, + pub organizer: String, + pub status: String, + pub url: Option, + pub color: Option, + /// Minutes before the start, at most two. + pub reminders: Vec, +} + +impl EventFields { + pub fn from_event(event: &CalendarEvent) -> Self { + let attendees = serde_json::from_str::>(&event.attendees) + .unwrap_or_default() + .into_iter() + .map(|a| a.trim().to_string()) + .filter(|a| !a.is_empty()) + .collect(); + let mut start = canonical_stored(&event.start_time); + let mut end = canonical_stored(&event.end_time); + if event.all_day { + start = date_part(&start); + end = date_part(&end); + } + Self { + title: event.title.clone(), + description: event.description.clone(), + location: event.location.clone(), + start, + end, + all_day: event.all_day, + rrule: event + .recurrence_rule + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()) + .map(str::to_string), + attendees, + organizer: event.organizer.trim().to_string(), + status: normalize_status(&event.status), + url: non_empty(event.meeting_url.as_deref()), + color: non_empty(event.color.as_deref()), + reminders: [event.reminder_minutes, event.reminder_extra_minutes] + .into_iter() + .flatten() + .filter(|m| *m >= 0) + .collect(), + } + } + + pub fn to_create_event(&self, uid: &str, source: &str) -> CreateEvent { + CreateEvent { + title: self.title.clone(), + description: self.description.clone(), + location: self.location.clone(), + start_time: self.start.clone(), + end_time: self.end.clone(), + all_day: self.all_day, + recurrence_rule: self.rrule.clone(), + attendees: serde_json::to_string(&self.attendees).unwrap_or_else(|_| "[]".into()), + organizer: self.organizer.clone(), + status: self.status.clone(), + source: source.to_string(), + source_uid: Some(uid.to_string()), + source_message_uid: None, + source_message_folder: None, + meeting_url: self.url.clone(), + color: self.color.clone(), + reminder_minutes: self.reminders.first().copied(), + reminder_extra_minutes: self.reminders.get(1).copied(), + } + } + + fn time_lines(&self) -> Vec { + let start = parse_stored(&self.start); + let mut end = parse_stored(&self.end); + let mut lines = Vec::new(); + let Some(mut start) = start else { + return lines; + }; + if self.all_day { + start = match start { + When::Date(d) => When::Date(d), + other => When::Date(other.naive_utc().date()), + }; + end = end.map(|e| match e { + When::Date(d) => When::Date(d), + other => When::Date(other.naive_utc().date()), + }); + } + let end = match end { + Some(e) if e.naive_utc() > start.naive_utc() => e, + // All-day events need an exclusive end date; timed events may be instantaneous. + _ if self.all_day => start.add(Duration::days(1)), + Some(e) if e.naive_utc() == start.naive_utc() => e, + _ => start, + }; + let (params, value) = start.ical(); + lines.push(format!("DTSTART{params}:{value}")); + let (params, value) = end.ical(); + lines.push(format!("DTEND{params}:{value}")); + lines + } + + fn alarm_lines(&self) -> Vec { + let mut lines = Vec::new(); + for minutes in &self.reminders { + lines.push("BEGIN:VALARM".into()); + lines.push("ACTION:DISPLAY".into()); + lines.push(format!("DESCRIPTION:{}", escape_text(&self.title))); + lines.push(format!("TRIGGER:-PT{minutes}M")); + lines.push("END:VALARM".into()); + } + lines + } +} + +fn date_part(value: &str) -> String { + value.get(..10).unwrap_or(value).to_string() +} + +fn non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + +fn normalize_status(status: &str) -> String { + match status.trim().to_ascii_lowercase().as_str() { + "tentative" => "tentative".into(), + "cancelled" | "canceled" => "cancelled".into(), + _ => "confirmed".into(), + } +} + +fn mail_address(value: &str) -> String { + let value = value.trim(); + match value.get(..7) { + Some(prefix) if prefix.eq_ignore_ascii_case("mailto:") => value[7..].trim().to_string(), + _ => value.to_string(), + } +} + +/// One component block at depth 2 (child of VCALENDAR). +struct Component { + kind: String, + props: Vec, + alarms: Vec>, +} + +impl Component { + fn prop(&self, name: &str) -> Option<&Line> { + self.props.iter().find(|l| l.name == name) + } +} + +fn components(text: &str) -> Result, IcalError> { + let mut stack: Vec = Vec::new(); + let mut out: Vec = Vec::new(); + let mut alarm: Option> = None; + let mut saw_calendar = false; + for raw in content::unfold(text) { + if let Some((begin, name)) = content::component_marker(&raw) { + if begin { + if stack.is_empty() { + if name != "VCALENDAR" { + return Err(IcalError::Invalid("expected BEGIN:VCALENDAR".into())); + } + saw_calendar = true; + } else if stack.len() == 1 { + out.push(Component { + kind: name.clone(), + props: Vec::new(), + alarms: Vec::new(), + }); + } else if stack.len() == 2 && name == "VALARM" { + alarm = Some(Vec::new()); + } + stack.push(name); + } else { + match stack.pop() { + Some(open) if open == name => {} + _ => return Err(IcalError::Invalid(format!("unbalanced END:{name}"))), + } + if stack.len() == 2 + && name == "VALARM" + && let (Some(lines), Some(component)) = (alarm.take(), out.last_mut()) + { + component.alarms.push(lines); + } + } + continue; + } + let Some(line) = content::parse_line(&raw) else { + continue; + }; + match stack.len() { + 2 => { + if let Some(component) = out.last_mut() { + component.props.push(line); + } + } + 3 => { + if let Some(lines) = alarm.as_mut() { + lines.push(line); + } + } + _ => {} + } + } + if !saw_calendar || !stack.is_empty() { + return Err(IcalError::Invalid("incomplete VCALENDAR object".into())); + } + Ok(out) +} + +/// A decoded calendar object resource. +#[derive(Debug)] +pub struct ParsedObject { + pub uid: String, + pub fields: EventFields, +} + +/// Decode an uploaded calendar object resource. +pub fn parse_object(text: &str) -> Result { + let all = components(text)?; + let mut events = Vec::new(); + let mut other_items = false; + for component in all { + match component.kind.as_str() { + "VEVENT" => events.push(component), + "VTODO" | "VJOURNAL" | "VFREEBUSY" | "VAVAILABILITY" => other_items = true, + _ => {} + } + } + if other_items { + return Err(IcalError::UnsupportedComponent); + } + if events.is_empty() { + return Err(IcalError::Invalid( + "calendar object contains no VEVENT".into(), + )); + } + let mut uid: Option = None; + for event in &events { + let this = event + .prop("UID") + .map(|l| l.text().trim().to_string()) + .filter(|u| !u.is_empty()) + .ok_or_else(|| IcalError::Invalid("VEVENT without UID".into()))?; + match &uid { + Some(existing) if *existing != this => { + return Err(IcalError::Invalid( + "all components of one resource must share a UID".into(), + )); + } + _ => uid = Some(this), + } + } + let master = events + .iter() + .find(|e| e.prop("RECURRENCE-ID").is_none()) + .unwrap_or(&events[0]); + let fields = fields_from_component(master)?; + Ok(ParsedObject { + uid: uid.unwrap_or_default(), + fields, + }) +} + +fn fields_from_component(event: &Component) -> Result { + let start = event + .prop("DTSTART") + .and_then(parse_property_time) + .ok_or_else(|| IcalError::Invalid("VEVENT without a valid DTSTART".into()))?; + let all_day = matches!(start, When::Date(_)); + let end = if let Some(end) = event.prop("DTEND").and_then(parse_property_time) { + end + } else if let Some(seconds) = event + .prop("DURATION") + .and_then(|l| parse_duration(&l.value)) + { + start.add(Duration::seconds(seconds.max(0))) + } else if all_day { + start.add(Duration::days(1)) + } else { + start + }; + let end = match (start, end) { + (When::Date(_), When::Date(_)) => end, + (When::Date(_), other) => When::Date(other.naive_utc().date()), + _ => end, + }; + + let mut reminders = Vec::new(); + for alarm in &event.alarms { + let Some(trigger) = alarm.iter().find(|l| l.name == "TRIGGER") else { + continue; + }; + let absolute = trigger + .param("VALUE") + .is_some_and(|v| v.eq_ignore_ascii_case("DATE-TIME")); + let related_end = trigger + .param("RELATED") + .is_some_and(|v| v.eq_ignore_ascii_case("END")); + if absolute || related_end { + continue; + } + if let Some(seconds) = parse_duration(&trigger.value) + && seconds <= 0 + { + let minutes = -seconds / 60; + if !reminders.contains(&minutes) && reminders.len() < 2 { + reminders.push(minutes); + } + } + } + + let text = |name: &str| event.prop(name).map(Line::text).unwrap_or_default(); + let url = event + .prop("URL") + .or_else(|| event.prop("X-GOOGLE-CONFERENCE")) + .or_else(|| event.prop("X-MICROSOFT-SKYPETEAMSMEETINGURL")) + .map(|l| l.text()); + Ok(EventFields { + title: text("SUMMARY"), + description: text("DESCRIPTION"), + location: text("LOCATION"), + start: start.canonical(), + end: end.canonical(), + all_day, + rrule: event + .prop("RRULE") + .map(|l| l.value.trim().to_string()) + .filter(|r| !r.is_empty()), + attendees: event + .props + .iter() + .filter(|l| l.name == "ATTENDEE") + .map(|l| mail_address(&l.value)) + .filter(|a| !a.is_empty()) + .collect(), + organizer: event + .prop("ORGANIZER") + .map(|l| mail_address(&l.value)) + .unwrap_or_default(), + status: normalize_status(&text("STATUS")), + url: non_empty(url.as_deref()), + color: non_empty(Some(&text("COLOR"))), + reminders, + }) +} + +/// `YYYYMMDDTHHMMSSZ` from a database timestamp (`datetime('now')`, MySQL/PostgreSQL text). +pub fn stamp(value: &str) -> String { + let digits: String = value + .chars() + .filter(char::is_ascii_digit) + .take(14) + .collect(); + if digits.len() == 14 { + format!("{}T{}Z", &digits[..8], &digits[8..]) + } else { + "19700101T000000Z".into() + } +} + +/// Serialize a webmail-created event (no stored CalDAV object) as iCalendar. +pub fn event_to_ical(event: &CalendarEvent) -> String { + let fields = EventFields::from_event(event); + let uid = event + .source_uid + .as_deref() + .filter(|u| !u.trim().is_empty()) + .unwrap_or(&event.id); + let mut lines = vec![ + "BEGIN:VCALENDAR".to_string(), + "VERSION:2.0".into(), + format!("PRODID:{PRODID}"), + "CALSCALE:GREGORIAN".into(), + "BEGIN:VEVENT".into(), + format!("UID:{}", escape_text(uid)), + format!("DTSTAMP:{}", stamp(&event.updated_at)), + format!("CREATED:{}", stamp(&event.created_at)), + format!("LAST-MODIFIED:{}", stamp(&event.updated_at)), + ]; + lines.extend(fields.time_lines()); + lines.extend(text_lines( + &fields, + &[Prop::Summary, Prop::Description, Prop::Location], + )); + if let Some(rule) = &fields.rrule { + lines.push(format!("RRULE:{rule}")); + } + lines.extend(text_lines( + &fields, + &[Prop::Status, Prop::Url, Prop::Color, Prop::Organizer], + )); + for attendee in &fields.attendees { + lines.push(attendee_line(attendee)); + } + lines.extend(fields.alarm_lines()); + lines.push("END:VEVENT".into()); + lines.push("END:VCALENDAR".into()); + content::join(&lines) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Prop { + Summary, + Description, + Location, + Status, + Url, + Color, + Organizer, +} + +impl Prop { + fn name(self) -> &'static str { + match self { + Prop::Summary => "SUMMARY", + Prop::Description => "DESCRIPTION", + Prop::Location => "LOCATION", + Prop::Status => "STATUS", + Prop::Url => "URL", + Prop::Color => "COLOR", + Prop::Organizer => "ORGANIZER", + } + } + + fn differs(self, a: &EventFields, b: &EventFields) -> bool { + match self { + Prop::Summary => a.title != b.title, + Prop::Description => a.description != b.description, + Prop::Location => a.location != b.location, + Prop::Status => a.status != b.status, + Prop::Url => a.url != b.url, + Prop::Color => a.color != b.color, + Prop::Organizer => !a.organizer.eq_ignore_ascii_case(&b.organizer), + } + } +} + +fn text_lines(fields: &EventFields, props: &[Prop]) -> Vec { + let mut lines = Vec::new(); + for prop in props { + let value = match prop { + Prop::Summary => Some(fields.title.clone()), + Prop::Description => Some(fields.description.clone()).filter(|v| !v.is_empty()), + Prop::Location => Some(fields.location.clone()).filter(|v| !v.is_empty()), + Prop::Status => Some(fields.status.to_ascii_uppercase()), + Prop::Url => fields.url.clone(), + Prop::Color => fields.color.clone(), + Prop::Organizer => { + if !fields.organizer.is_empty() { + lines.push(format!("ORGANIZER:mailto:{}", fields.organizer)); + } + None + } + }; + if let Some(value) = value { + let value = match prop { + // URI values are not TEXT-escaped. + Prop::Url => value, + _ => escape_text(&value), + }; + lines.push(format!("{}:{value}", prop.name())); + } + } + lines +} + +fn attendee_line(email: &str) -> String { + format!("ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{email}") +} + +/// Re-apply webmail edits to a stored CalDAV object. Returns `None` when the +/// stored object already matches the row. +pub fn patch_object(raw: &str, event: &CalendarEvent) -> Option { + let wanted = EventFields::from_event(event); + let current = match parse_object(raw) { + Ok(parsed) => parsed.fields, + // The stored object is unusable; fall back to a generated one. + Err(_) => return Some(event_to_ical(event)), + }; + if current == wanted { + return None; + } + + let changed_props: Vec = [ + Prop::Summary, + Prop::Description, + Prop::Location, + Prop::Status, + Prop::Url, + Prop::Color, + Prop::Organizer, + ] + .into_iter() + .filter(|p| p.differs(¤t, &wanted)) + .collect(); + let time_changed = current.start != wanted.start + || current.end != wanted.end + || current.all_day != wanted.all_day; + let rrule_changed = current.rrule != wanted.rrule; + let attendees_changed = current + .attendees + .iter() + .map(|a| a.to_ascii_lowercase()) + .collect::>() + != wanted + .attendees + .iter() + .map(|a| a.to_ascii_lowercase()) + .collect::>(); + let alarms_changed = current.reminders != wanted.reminders; + + let mut drop: Vec<&str> = changed_props.iter().map(|p| p.name()).collect(); + drop.extend(["DTSTAMP", "LAST-MODIFIED"]); + if time_changed { + drop.extend(["DTSTART", "DTEND", "DURATION"]); + } + if rrule_changed { + drop.push("RRULE"); + } + if time_changed || rrule_changed { + drop.push("SEQUENCE"); + } + if changed_props.contains(&Prop::Url) { + drop.extend(["X-GOOGLE-CONFERENCE", "X-MICROSOFT-SKYPETEAMSMEETINGURL"]); + } + let wanted_attendees: Vec = wanted + .attendees + .iter() + .map(|a| a.to_ascii_lowercase()) + .collect(); + + let lines = content::unfold(raw); + let master = master_vevent_start(&lines)?; + let now = Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); + + let mut out: Vec = Vec::with_capacity(lines.len() + 8); + let mut depth = 0usize; + let mut master_depth: Option = None; + let mut skip_depth: Option = None; + let mut old_sequence = 0i64; + let mut kept_attendees: Vec = Vec::new(); + + for (index, line) in lines.iter().enumerate() { + let marker = content::component_marker(line); + if let Some((begin, name)) = &marker { + if *begin { + depth += 1; + if index == master { + master_depth = Some(depth); + } else if master_depth == Some(depth - 1) + && name == "VALARM" + && alarms_changed + && skip_depth.is_none() + { + skip_depth = Some(depth); + } + } else { + let closing = depth; + depth = depth.saturating_sub(1); + if skip_depth == Some(closing) { + skip_depth = None; + continue; + } + if master_depth == Some(closing) { + // Append the replacement properties before END:VEVENT. + out.push(format!("DTSTAMP:{now}")); + out.push(format!("LAST-MODIFIED:{now}")); + if time_changed || rrule_changed { + out.push(format!("SEQUENCE:{}", old_sequence + 1)); + } + if time_changed { + out.extend(wanted.time_lines()); + } + if rrule_changed && let Some(rule) = &wanted.rrule { + out.push(format!("RRULE:{rule}")); + } + out.extend(text_lines(&wanted, &changed_props)); + if attendees_changed { + for attendee in &wanted.attendees { + if !kept_attendees.contains(&attendee.to_ascii_lowercase()) { + out.push(attendee_line(attendee)); + } + } + } + if alarms_changed { + out.extend(wanted.alarm_lines()); + } + master_depth = None; + } + } + if skip_depth.is_none() { + out.push(line.clone()); + } + continue; + } + if skip_depth.is_some() { + continue; + } + if master_depth == Some(depth) + && let Some(parsed) = content::parse_line(line) + { + if parsed.name == "SEQUENCE" { + old_sequence = parsed.value.trim().parse().unwrap_or(0); + } + if drop.contains(&parsed.name.as_str()) { + continue; + } + if parsed.name == "ATTENDEE" && attendees_changed { + let address = mail_address(&parsed.value).to_ascii_lowercase(); + if !wanted_attendees.contains(&address) { + continue; + } + kept_attendees.push(address); + } + } + out.push(line.clone()); + } + Some(content::join(&out)) +} + +/// Index of the `BEGIN:VEVENT` line of the master component. +fn master_vevent_start(lines: &[String]) -> Option { + let mut depth = 0usize; + let mut first: Option = None; + let mut current: Option<(usize, bool)> = None; + for (index, line) in lines.iter().enumerate() { + if let Some((begin, name)) = content::component_marker(line) { + if begin { + depth += 1; + if depth == 2 && name == "VEVENT" { + current = Some((index, false)); + first.get_or_insert(index); + } + } else { + if depth == 2 + && name == "VEVENT" + && let Some((start, is_override)) = current.take() + && !is_override + { + return Some(start); + } + depth = depth.saturating_sub(1); + } + continue; + } + if depth == 2 + && let Some((_, is_override)) = current.as_mut() + && line + .get(..13) + .is_some_and(|p| p.eq_ignore_ascii_case("RECURRENCE-ID")) + { + *is_override = true; + } + } + first +} + +/// Parse a CalDAV `time-range` attribute (`20261101T000000Z`). +pub fn parse_range_bound(value: &str) -> Option { + parse_ical_value(value, None, false).map(|w| w.naive_utc()) +} + +/// RFC 4791 time-range overlap test for a stored event. Recurring events are +/// always considered overlapping when they start before the range end, since +/// RustBox does not expand recurrences server side. +pub fn overlaps( + event: &CalendarEvent, + start: Option, + end: Option, +) -> bool { + let Some(event_start) = parse_stored(&event.start_time) else { + return true; + }; + let event_end = parse_stored(&event.end_time).unwrap_or(event_start); + let s = event_start.naive_utc(); + let mut e = event_end.naive_utc(); + if e <= s && matches!(event_start, When::Date(_)) { + e = s + Duration::days(1); + } + if let Some(range_end) = end + && s >= range_end + { + return false; + } + let recurring = event + .recurrence_rule + .as_deref() + .is_some_and(|r| !r.trim().is_empty()); + if recurring { + return true; + } + match start { + Some(range_start) if e == s => s >= range_start, + Some(range_start) => e > range_start, + None => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_event() -> CalendarEvent { + CalendarEvent { + id: "e1".into(), + title: "Standup".into(), + description: String::new(), + location: String::new(), + start_time: "2026-11-10T08:00:00.000Z".into(), + end_time: "2026-11-10T08:30:00.000Z".into(), + all_day: false, + recurrence_rule: None, + attendees: "[]".into(), + organizer: String::new(), + status: "confirmed".into(), + source: "manual".into(), + source_uid: None, + source_message_uid: None, + source_message_folder: None, + meeting_url: None, + color: None, + reminder_minutes: Some(15), + reminder_extra_minutes: None, + created_at: "2026-11-01 10:00:00".into(), + updated_at: "2026-11-02 11:22:33".into(), + } + } + + const PHONE_EVENT: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//ActivePush//EN\r\n\ +BEGIN:VTIMEZONE\r\nTZID:Europe/Warsaw\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n\ +BEGIN:VEVENT\r\nUID:abc-123\r\nDTSTAMP:20261101T000000Z\r\nSEQUENCE:2\r\nDTSTART;TZID=Europe/Warsaw:20261110T090000\r\nDTEND;TZID=Europe/Warsaw:20261110T100000\r\n\ +SUMMARY:Dentist\\, checkup\r\nRRULE:FREQ=WEEKLY;COUNT=4\r\nEXDATE;TZID=Europe/Warsaw:20261117T090000\r\nX-VENDOR-FLAG:keep-me\r\n\ +ATTENDEE;CN=Ann;PARTSTAT=ACCEPTED:mailto:ann@example.com\r\n\ +BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT30M\r\nEND:VALARM\r\nEND:VEVENT\r\n\ +BEGIN:VEVENT\r\nUID:abc-123\r\nRECURRENCE-ID;TZID=Europe/Warsaw:20261124T090000\r\nDTSTART;TZID=Europe/Warsaw:20261124T120000\r\nDTEND;TZID=Europe/Warsaw:20261124T130000\r\nSUMMARY:Moved\r\nEND:VEVENT\r\n\ +END:VCALENDAR\r\n"; + + #[test] + fn parses_master_with_timezone_alarm_and_override() { + let parsed = parse_object(PHONE_EVENT).unwrap(); + assert_eq!(parsed.uid, "abc-123"); + let f = parsed.fields; + assert_eq!(f.title, "Dentist, checkup"); + assert_eq!(f.start, "2026-11-10T08:00:00Z"); + assert_eq!(f.end, "2026-11-10T09:00:00Z"); + assert_eq!(f.rrule.as_deref(), Some("FREQ=WEEKLY;COUNT=4")); + assert_eq!(f.reminders, vec![30]); + assert_eq!(f.attendees, vec!["ann@example.com"]); + } + + #[test] + fn rejects_todos_and_uid_mismatch() { + let todo = "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + assert_eq!( + parse_object(todo).unwrap_err(), + IcalError::UnsupportedComponent + ); + let mismatch = PHONE_EVENT.replacen("UID:abc-123", "UID:other", 1); + assert!(matches!( + parse_object(&mismatch), + Err(IcalError::Invalid(_)) + )); + } + + #[test] + fn all_day_and_duration() { + let ics = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:d\nDTSTART;VALUE=DATE:20261224\nSUMMARY:Eve\nEND:VEVENT\nEND:VCALENDAR\n"; + let f = parse_object(ics).unwrap().fields; + assert!(f.all_day); + assert_eq!( + (f.start.as_str(), f.end.as_str()), + ("2026-12-24", "2026-12-25") + ); + let ics = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:d\nDTSTART:20261224T100000Z\nDURATION:PT1H30M\nEND:VEVENT\nEND:VCALENDAR\n"; + assert_eq!( + parse_object(ics).unwrap().fields.end, + "2026-12-24T11:30:00Z" + ); + assert_eq!(parse_duration("-P1DT2H"), Some(-93_600)); + assert_eq!(parse_duration("P1W"), Some(604_800)); + } + + #[test] + fn generated_event_roundtrips() { + let event = sample_event(); + let ics = event_to_ical(&event); + assert!(ics.contains("UID:e1\r\n")); + assert!(ics.contains("DTSTAMP:20261102T112233Z\r\n")); + assert!(ics.contains("DTSTART:20261110T080000Z\r\n")); + let parsed = parse_object(&ics).unwrap(); + assert_eq!(parsed.fields, EventFields::from_event(&event)); + } + + #[test] + fn unchanged_row_keeps_stored_object() { + let parsed = parse_object(PHONE_EVENT).unwrap(); + let create = parsed.fields.to_create_event(&parsed.uid, "caldav"); + let mut event = sample_event(); + event.title = create.title; + event.start_time = create.start_time; + event.end_time = create.end_time; + event.recurrence_rule = create.recurrence_rule; + event.attendees = create.attendees; + event.reminder_minutes = create.reminder_minutes; + event.source_uid = create.source_uid; + assert!(patch_object(PHONE_EVENT, &event).is_none()); + + // Title edited in the webmail: only SUMMARY (and stamps) change. + event.title = "Dentist (moved)".into(); + let patched = patch_object(PHONE_EVENT, &event).unwrap(); + assert!(patched.contains("SUMMARY:Dentist (moved)\r\n")); + assert!(patched.contains("DTSTART;TZID=Europe/Warsaw:20261110T090000\r\n")); + assert!(patched.contains("EXDATE;TZID=Europe/Warsaw:20261117T090000\r\n")); + assert!(patched.contains("X-VENDOR-FLAG:keep-me\r\n")); + assert!(patched.contains("RECURRENCE-ID;TZID=Europe/Warsaw:20261124T090000\r\n")); + assert!(patched.contains("SUMMARY:Moved\r\n")); + assert!(patched.contains("ATTENDEE;CN=Ann;PARTSTAT=ACCEPTED:mailto:ann@example.com")); + assert_eq!(patched.matches("TRIGGER:-PT30M").count(), 1); + assert_eq!( + parse_object(&patched).unwrap().fields.title, + "Dentist (moved)" + ); + + // Time and reminder edited: DTSTART/DTEND rewritten, SEQUENCE bumped, alarm replaced. + event.start_time = "2026-11-10T10:00:00.000Z".into(); + event.end_time = "2026-11-10T11:00:00.000Z".into(); + event.reminder_minutes = Some(60); + let patched = patch_object(PHONE_EVENT, &event).unwrap(); + assert!(patched.contains("DTSTART:20261110T100000Z\r\n")); + assert!(patched.contains("SEQUENCE:3\r\n")); + assert!(patched.contains("TRIGGER:-PT60M")); + assert!(!patched.contains("TRIGGER:-PT30M")); + let reparsed = parse_object(&patched).unwrap().fields; + assert_eq!(reparsed.start, "2026-11-10T10:00:00Z"); + assert_eq!(reparsed.reminders, vec![60]); + // The override instance is untouched. + assert!(patched.contains("DTSTART;TZID=Europe/Warsaw:20261124T120000\r\n")); + } + + #[test] + fn time_range_overlap() { + let event = sample_event(); + let at = |s: &str| parse_range_bound(s); + assert!(overlaps( + &event, + at("20261110T000000Z"), + at("20261111T000000Z") + )); + assert!(!overlaps(&event, at("20261111T000000Z"), None)); + assert!(!overlaps(&event, None, at("20261110T080000Z"))); + let mut recurring = event.clone(); + recurring.recurrence_rule = Some("FREQ=DAILY".into()); + assert!(overlaps(&recurring, at("20270101T000000Z"), None)); + } + + #[test] + fn resolves_vendor_tzids() { + assert!(resolve_tzid("/mozilla.org/20050126_1/Europe/Warsaw").is_some()); + assert!(resolve_tzid("Central European Standard Time").is_some()); + assert!(resolve_tzid("Mars/Olympus").is_none()); + } +} diff --git a/src/dav/mod.rs b/src/dav/mod.rs new file mode 100644 index 0000000..3790120 --- /dev/null +++ b/src/dav/mod.rs @@ -0,0 +1,1471 @@ +//! Built-in CalDAV (RFC 4791) and CardDAV (RFC 6352) server. +//! +//! Enabled with `[dav] enabled = true`. Every account gets one calendar and one +//! address book backed by the same tables as the webmail, so events and contacts +//! are shared between the webmail, phones (directly or through an ActiveSync +//! gateway such as ActivePush) and desktop clients. +//! +//! URL layout (below `/dav`): +//! +//! ```text +//! /principals// principal +//! /calendars// calendar home +//! /calendars//default/ the calendar (VEVENT) +//! /calendars//default/.ics calendar object resource +//! /addressbooks// address book home +//! /addressbooks//default/ the address book +//! /addressbooks//default/.vcf address object resource +//! ``` +//! +//! Authentication is HTTP Basic with the IMAP credentials, validated against the +//! configured IMAP server (cached for `dav.auth_cache_secs`) and protected by the +//! same brute-force guard as the webmail login. + +pub mod content; +pub mod ical; +pub mod vcard; +pub mod xml; + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::Extension; +use axum::Router; +use axum::body::Bytes; +use axum::extract::{DefaultBodyLimit, OriginalUri}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::any; +use base64::Engine as _; +use dashmap::DashMap; +use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; +use sha2::{Digest, Sha256}; + +use crate::auth::imap_auth::{self, AuthResult}; +use crate::auth::login_guard::LoginGuard; +use crate::config::AppConfig; +use crate::db::dav::{DavContact, DavEvent}; +use crate::db::pool::{Connection, UserDb}; +use xml::{ + Element, MultiStatus, NS_APPLE, NS_CALDAV, NS_CARDDAV, NS_CS, NS_DAV, PropRequest, QName, + escape, +}; + +/// Name of the single calendar / address book collection of every account. +pub const COLLECTION: &str = "default"; +const DAV_HEADER: &str = "1, 3, calendar-access, addressbook"; +const ALLOW: &str = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, REPORT"; +const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8"; +const ICAL_CONTENT_TYPE: &str = "text/calendar; charset=utf-8"; +const VCARD_CONTENT_TYPE: &str = "text/vcard; charset=utf-8"; +/// Cached logins kept before expired entries are swept. +const AUTH_CACHE_SWEEP_AT: usize = 4096; + +const PATH_SEGMENT: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'/') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}') + .add(b'\\') + .add(b'^') + .add(b'|') + .add(b'[') + .add(b']'); + +fn encode_segment(segment: &str) -> String { + utf8_percent_encode(segment, PATH_SEGMENT).to_string() +} + +fn decode_segment(segment: &str) -> String { + percent_decode_str(segment).decode_utf8_lossy().into_owned() +} + +/// Remembered successful Basic logins (`sha256(login\0password)` -> expiry). +#[derive(Default)] +pub struct DavAuthCache { + entries: DashMap<[u8; 32], Instant>, +} + +impl DavAuthCache { + fn key(login: &str, password: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(login.as_bytes()); + hasher.update([0u8]); + hasher.update(password.as_bytes()); + hasher.finalize().into() + } + + fn is_valid(&self, key: &[u8; 32]) -> bool { + self.entries + .get(key) + .is_some_and(|expiry| *expiry > Instant::now()) + } + + fn remember(&self, key: [u8; 32], ttl: Duration) { + if ttl.is_zero() { + return; + } + if self.entries.len() >= AUTH_CACHE_SWEEP_AT { + let now = Instant::now(); + self.entries.retain(|_, expiry| *expiry > now); + } + self.entries.insert(key, Instant::now() + ttl); + } +} + +/// `/dav` without a trailing slash. +pub fn dav_root(config: &AppConfig) -> String { + format!( + "{}/dav", + config.normalized_base_path().trim_end_matches('/') + ) +} + +/// Routes mounted next to `/api` (inside the base path). +pub fn routes(config: &AppConfig) -> Router { + routes_with_cache(config, Arc::new(DavAuthCache::default())) +} + +fn routes_with_cache(config: &AppConfig, cache: Arc) -> Router { + Router::new() + .route("/dav", any(handle)) + .route("/dav/", any(handle)) + .route("/dav/{*path}", any(handle)) + .layer(DefaultBodyLimit::max( + config.dav.max_resource_size.saturating_add(64 * 1024), + )) + .layer(Extension(cache)) +} + +/// RFC 6764 service discovery; must live at the host root. +pub fn well_known_routes() -> Router { + Router::new() + .route("/.well-known/caldav", any(well_known_redirect)) + .route("/.well-known/carddav", any(well_known_redirect)) +} + +async fn well_known_redirect(Extension(config): Extension>) -> Response { + let mut response = StatusCode::MOVED_PERMANENTLY.into_response(); + if let Ok(location) = HeaderValue::from_str(&format!("{}/", dav_root(&config))) { + response.headers_mut().insert(header::LOCATION, location); + } + response +} + +/// A response computed off the async runtime. +struct Reply { + status: StatusCode, + content_type: Option<&'static str>, + headers: Vec<(HeaderName, String)>, + body: String, +} + +impl Reply { + fn status(status: StatusCode) -> Self { + Self { + status, + content_type: None, + headers: Vec::new(), + body: String::new(), + } + } + + fn text(status: StatusCode, body: &str) -> Self { + Self { + status, + content_type: Some("text/plain; charset=utf-8"), + headers: Vec::new(), + body: body.to_string(), + } + } + + fn xml(status: StatusCode, body: String) -> Self { + Self { + status, + content_type: Some(XML_CONTENT_TYPE), + headers: Vec::new(), + body, + } + } + + fn multistatus(body: MultiStatus) -> Self { + Self::xml(StatusCode::MULTI_STATUS, body.finish()) + } + + /// Failed precondition with a `` body. + fn precondition(status: StatusCode, inner: &str) -> Self { + Self::xml(status, xml::error_body(inner)) + } + + fn with_header(mut self, name: HeaderName, value: String) -> Self { + self.headers.push((name, value)); + self + } + + fn into_response(self, head_only: bool) -> Response { + let mut response = if head_only { + self.status.into_response() + } else { + (self.status, self.body).into_response() + }; + let headers = response.headers_mut(); + if let Some(content_type) = self.content_type { + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + } + for (name, value) in self.headers { + if let Ok(value) = HeaderValue::from_str(&value) { + headers.insert(name, value); + } + } + headers.insert( + HeaderName::from_static("dav"), + HeaderValue::from_static(DAV_HEADER), + ); + response + } +} + +/// Per-request context. +#[derive(Clone)] +struct Ctx { + root: String, + login: String, + calendar_name: String, + addressbook_name: String, + max_size: usize, +} + +impl Ctx { + fn user(&self) -> String { + encode_segment(&self.login) + } + fn principal(&self) -> String { + format!("{}/principals/{}/", self.root, self.user()) + } + fn calendar_home(&self) -> String { + format!("{}/calendars/{}/", self.root, self.user()) + } + fn calendar(&self) -> String { + format!("{}{COLLECTION}/", self.calendar_home()) + } + fn event(&self, name: &str) -> String { + format!("{}{}", self.calendar(), encode_segment(name)) + } + fn addressbook_home(&self) -> String { + format!("{}/addressbooks/{}/", self.root, self.user()) + } + fn addressbook(&self) -> String { + format!("{}{COLLECTION}/", self.addressbook_home()) + } + fn card(&self, name: &str) -> String { + format!("{}{}", self.addressbook(), encode_segment(name)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Target { + Root, + Principal, + CalendarHome, + Calendar, + Event(String), + AddressBookHome, + AddressBook, + Card(String), + Forbidden, + NotFound, +} + +fn parse_target(rest: &str, login: &str) -> Target { + let segments: Vec = rest + .split('/') + .filter(|s| !s.is_empty()) + .map(decode_segment) + .collect(); + let Some(first) = segments.first() else { + return Target::Root; + }; + let Some(user) = segments.get(1) else { + return Target::NotFound; + }; + if !user.eq_ignore_ascii_case(login) { + return Target::Forbidden; + } + let collection = segments.get(2).map(String::as_str); + let object = segments.get(3).cloned(); + if segments.len() > 4 { + return Target::NotFound; + } + match (first.as_str(), collection, object) { + ("principals", None, None) => Target::Principal, + ("calendars", None, None) => Target::CalendarHome, + ("calendars", Some(COLLECTION), None) => Target::Calendar, + ("calendars", Some(COLLECTION), Some(name)) => Target::Event(name), + ("addressbooks", None, None) => Target::AddressBookHome, + ("addressbooks", Some(COLLECTION), None) => Target::AddressBook, + ("addressbooks", Some(COLLECTION), Some(name)) => Target::Card(name), + _ => Target::NotFound, + } +} + +fn unauthorized() -> Response { + let mut response = + Reply::text(StatusCode::UNAUTHORIZED, "Authentication required\n").into_response(false); + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + HeaderValue::from_static("Basic realm=\"RustBox DAV\", charset=\"UTF-8\""), + ); + response +} + +fn basic_credentials(headers: &HeaderMap) -> Option<(String, String)> { + let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; + let (scheme, encoded) = value.trim().split_once(' ')?; + if !scheme.eq_ignore_ascii_case("basic") { + return None; + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .ok()?; + let decoded = String::from_utf8(decoded).ok()?; + let (login, password) = decoded.split_once(':')?; + let login = login.trim(); + if login.is_empty() || password.is_empty() { + return None; + } + Some((login.to_string(), password.to_string())) +} + +/// Validate Basic credentials against IMAP; returns the login on success. +#[allow(clippy::result_large_err)] +async fn authenticate( + config: &AppConfig, + cache: &DavAuthCache, + guard: Option<&LoginGuard>, + client_ip: std::net::IpAddr, + headers: &HeaderMap, +) -> Result { + let Some((login, password)) = basic_credentials(headers) else { + return Err(unauthorized()); + }; + let key = DavAuthCache::key(&login, &password); + if cache.is_valid(&key) { + return Ok(login); + } + if let Some(guard) = guard + && let Err(blocked) = guard.check(&login, client_ip) + { + tracing::warn!( + source_ip = %client_ip, + scope = blocked.scope.as_str(), + "DAV login refused: brute-force lockout active" + ); + let mut response = Reply::text(StatusCode::TOO_MANY_REQUESTS, "Too many failed logins\n") + .into_response(false); + if let Ok(value) = HeaderValue::from_str(&blocked.retry_after_secs().to_string()) { + response.headers_mut().insert(header::RETRY_AFTER, value); + } + return Err(response); + } + let Some(imap_host) = config + .imap_host + .clone() + .filter(|host| !host.trim().is_empty()) + else { + return Err(Reply::text( + StatusCode::SERVICE_UNAVAILABLE, + "IMAP server is not configured in RustBox\n", + ) + .into_response(false)); + }; + let result = imap_auth::validate_imap_credentials( + &imap_host, + config.imap_port, + config.tls_enabled, + config.tls_verify_for_host(&imap_host), + &login, + &password, + ) + .await; + match result { + AuthResult::Success => { + if let Some(guard) = guard { + guard.record_success(&login); + } + let user_hash = crate::auth::user_data::hash_email(&login); + let data_dir = config.data_dir.clone(); + let provisioned = tokio::task::spawn_blocking(move || { + crate::auth::user_data::provision_user_database(&data_dir, &user_hash) + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r); + if let Err(error) = provisioned { + tracing::error!(%error, "DAV: failed to provision user database"); + return Err(Reply::status(StatusCode::INTERNAL_SERVER_ERROR).into_response(false)); + } + cache.remember(key, Duration::from_secs(config.dav.auth_cache_secs)); + Ok(login) + } + AuthResult::InvalidCredentials => { + if let Some(guard) = guard + && let Some(locked) = guard.record_failure(&login, client_ip) + { + tracing::warn!( + source_ip = %client_ip, + scope = locked.scope.as_str(), + "DAV login brute-force lockout triggered" + ); + } + tracing::info!(source_ip = %client_ip, "DAV login failed"); + Err(unauthorized()) + } + AuthResult::ServerUnreachable(error) => { + tracing::warn!(%error, "DAV login: IMAP server unreachable"); + Err( + Reply::text(StatusCode::SERVICE_UNAVAILABLE, "IMAP server unreachable\n") + .into_response(false), + ) + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn handle( + Extension(config): Extension>, + Extension(cache): Extension>, + guard: Option>>, + OriginalUri(uri): OriginalUri, + method: Method, + headers: HeaderMap, + extensions: axum::http::Extensions, + body: Bytes, +) -> Response { + if method == Method::OPTIONS { + let mut response = Reply::status(StatusCode::OK).into_response(false); + response + .headers_mut() + .insert(header::ALLOW, HeaderValue::from_static(ALLOW)); + return response; + } + + let client_ip = crate::routes::auth::login_client_ip(&config, &extensions, &headers); + let guard = guard.as_ref().map(|Extension(g)| g.as_ref()); + let login = match authenticate(&config, &cache, guard, client_ip, &headers).await { + Ok(login) => login, + Err(response) => return response, + }; + + let root = dav_root(&config); + let rest = uri.path().strip_prefix(root.as_str()).unwrap_or("/"); + let target = parse_target(rest, &login); + let ctx = Ctx { + root, + login: login.clone(), + calendar_name: config.dav.calendar_name.clone(), + addressbook_name: config.dav.addressbook_name.clone(), + max_size: config.dav.max_resource_size, + }; + let head_only = method == Method::HEAD; + match target { + Target::Forbidden => { + return Reply::text(StatusCode::FORBIDDEN, "Not your collection\n") + .into_response(head_only); + } + Target::NotFound if matches!(method.as_str(), "MKCALENDAR" | "MKCOL") => { + return Reply::text( + StatusCode::FORBIDDEN, + "RustBox provides exactly one calendar and one address book per account\n", + ) + .into_response(false); + } + Target::NotFound => return Reply::status(StatusCode::NOT_FOUND).into_response(head_only), + _ => {} + } + + let request = Request { + method: method.as_str().to_ascii_uppercase(), + depth: headers + .get("depth") + .and_then(|v| v.to_str().ok()) + .map(|v| v.trim().to_string()), + if_match: header_string(&headers, header::IF_MATCH), + if_none_match: header_string(&headers, header::IF_NONE_MATCH), + body, + }; + let user_hash = crate::auth::user_data::hash_email(&login); + let db = UserDb::new(&config.data_dir, &user_hash); + let result = db + .run(move |conn| Ok(dispatch(conn, &ctx, &target, &request))) + .await; + match result { + Ok(reply) => reply.into_response(head_only), + Err(error) => { + tracing::error!(%error, "DAV request failed"); + Reply::status(StatusCode::INTERNAL_SERVER_ERROR).into_response(head_only) + } + } +} + +fn header_string(headers: &HeaderMap, name: HeaderName) -> Option { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +struct Request { + method: String, + depth: Option, + if_match: Option, + if_none_match: Option, + body: Bytes, +} + +fn dispatch(conn: &Connection, ctx: &Ctx, target: &Target, request: &Request) -> Reply { + let result = match request.method.as_str() { + "PROPFIND" => propfind(conn, ctx, target, request), + "REPORT" => report(conn, ctx, target, request), + "GET" | "HEAD" => get(conn, ctx, target, request), + "PUT" => put(conn, ctx, target, request), + "DELETE" => delete(conn, target, request), + "PROPPATCH" => proppatch(target, request, ctx), + "MKCALENDAR" | "MKCOL" => Ok(Reply::text( + StatusCode::FORBIDDEN, + "RustBox provides exactly one calendar and one address book per account\n", + )), + _ => Ok(Reply::status(StatusCode::METHOD_NOT_ALLOWED) + .with_header(header::ALLOW, ALLOW.to_string())), + }; + result.unwrap_or_else(|error| { + tracing::error!(%error, method = %request.method, "DAV request failed"); + Reply::status(StatusCode::INTERNAL_SERVER_ERROR) + }) +} + +// ------------------------------------------------------------------ helpers + +pub fn etag_of(body: &str) -> String { + let digest = Sha256::digest(body.as_bytes()); + let hex: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect(); + format!("\"{hex}\"") +} + +fn collection_tag(items: impl Iterator) -> String { + let mut pairs: Vec<(String, String)> = items.collect(); + pairs.sort(); + let mut hasher = Sha256::new(); + for (name, etag) in &pairs { + hasher.update(name.as_bytes()); + hasher.update([0u8]); + hasher.update(etag.as_bytes()); + hasher.update(b"\n"); + } + let hex: String = hasher + .finalize() + .iter() + .take(16) + .map(|b| format!("{b:02x}")) + .collect(); + format!("rustbox-{hex}") +} + +/// `If-Match` / `If-None-Match` comparison (`*`, lists, weak validators). +fn etag_matches(header: &str, etag: &str) -> bool { + header.split(',').any(|candidate| { + let candidate = candidate.trim(); + candidate == "*" || candidate.trim_start_matches("W/") == etag + }) +} + +fn http_date(timestamp: &str) -> Option { + let digits: String = timestamp + .chars() + .filter(char::is_ascii_digit) + .take(14) + .collect(); + let parsed = chrono::NaiveDateTime::parse_from_str(&digits, "%Y%m%d%H%M%S").ok()?; + Some(parsed.format("%a, %d %b %Y %H:%M:%S GMT").to_string()) +} + +/// The resource name a multiget `` refers to. +fn href_name(href: &str) -> Option { + let path = match href.find("://") { + Some(scheme_end) => { + let after = &href[scheme_end + 3..]; + after.find('/').map(|i| &after[i..]).unwrap_or("/") + } + None => href, + }; + let path = path.split(['?', '#']).next().unwrap_or(path); + let last = path.trim_end_matches('/').rsplit('/').next()?; + let name = decode_segment(last); + (!name.is_empty()).then_some(name) +} + +fn valid_resource_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 255 + && name != "." + && name != ".." + && !name.contains('/') + && !name.chars().any(char::is_control) +} + +// ------------------------------------------------------------- resources + +enum Resource { + Root, + Principal, + CalendarHome, + Calendar { + ctag: Option, + }, + Event { + item: DavEvent, + body: String, + etag: String, + }, + AddressBookHome, + AddressBook { + ctag: Option, + }, + Card { + item: DavContact, + body: String, + etag: String, + }, +} + +impl Resource { + fn event(item: DavEvent) -> Self { + let body = item.body(); + let etag = etag_of(&body); + Resource::Event { item, body, etag } + } + + fn card(item: DavContact) -> Self { + let body = item.body(); + let etag = etag_of(&body); + Resource::Card { item, body, etag } + } + + fn href(&self, ctx: &Ctx) -> String { + match self { + Resource::Root => format!("{}/", ctx.root), + Resource::Principal => ctx.principal(), + Resource::CalendarHome => ctx.calendar_home(), + Resource::Calendar { .. } => ctx.calendar(), + Resource::Event { item, .. } => ctx.event(&item.resource_name()), + Resource::AddressBookHome => ctx.addressbook_home(), + Resource::AddressBook { .. } => ctx.addressbook(), + Resource::Card { item, .. } => ctx.card(&item.resource_name()), + } + } +} + +fn load_events(conn: &Connection) -> Result, String> { + Ok(crate::db::dav::list_events(conn)? + .into_iter() + .map(Resource::event) + .collect()) +} + +fn load_cards(conn: &Connection) -> Result, String> { + Ok(crate::db::dav::list_contacts(conn)? + .into_iter() + .map(Resource::card) + .collect()) +} + +fn tag_of(resources: &[Resource]) -> String { + collection_tag(resources.iter().filter_map(|r| match r { + Resource::Event { item, etag, .. } => Some((item.resource_name(), etag.clone())), + Resource::Card { item, etag, .. } => Some((item.resource_name(), etag.clone())), + _ => None, + })) +} + +const ALLPROP: &[(&str, &str)] = &[ + (NS_DAV, "resourcetype"), + (NS_DAV, "displayname"), + (NS_DAV, "getetag"), + (NS_DAV, "getcontenttype"), + (NS_DAV, "getcontentlength"), + (NS_DAV, "getlastmodified"), + (NS_CS, "getctag"), +]; + +fn wants(request: &PropRequest, ns: &str, name: &str) -> bool { + match request { + PropRequest::Props(props) => props.iter().any(|p| p.is(ns, name)), + _ => true, + } +} + +fn privileges(write: bool) -> String { + let mut inner = String::from( + "", + ); + if write { + inner.push_str(""); + } + format!("{inner}") +} + +fn href_prop(name: &str, href: &str) -> String { + format!("<{name}>{}", escape(href)) +} + +/// Serialize one property of a resource, or `None` if it is not defined there. +fn property(ctx: &Ctx, resource: &Resource, prop: &QName) -> Option { + let ns = prop.ns.as_str(); + let name = prop.name.as_str(); + match (ns, name) { + (NS_DAV, "resourcetype") => Some(match resource { + Resource::Principal => { + "".into() + } + Resource::Calendar { .. } => { + "".into() + } + Resource::AddressBook { .. } => { + "".into() + } + Resource::Event { .. } | Resource::Card { .. } => "".into(), + _ => "".into(), + }), + (NS_DAV, "displayname") => { + let value = match resource { + Resource::Root => "RustBox DAV".to_string(), + Resource::Principal => ctx.login.clone(), + Resource::CalendarHome | Resource::AddressBookHome => ctx.login.clone(), + Resource::Calendar { .. } => ctx.calendar_name.clone(), + Resource::AddressBook { .. } => ctx.addressbook_name.clone(), + _ => return None, + }; + Some(format!("{}", escape(&value))) + } + (NS_DAV, "current-user-principal") => { + Some(href_prop("d:current-user-principal", &ctx.principal())) + } + (NS_DAV, "principal-URL") if matches!(resource, Resource::Principal) => { + Some(href_prop("d:principal-URL", &ctx.principal())) + } + (NS_DAV, "principal-collection-set") => Some(href_prop( + "d:principal-collection-set", + &format!("{}/principals/", ctx.root), + )), + (NS_DAV, "owner") if !matches!(resource, Resource::Root) => { + Some(href_prop("d:owner", &ctx.principal())) + } + (NS_DAV, "current-user-privilege-set") => Some(privileges(matches!( + resource, + Resource::Calendar { .. } + | Resource::AddressBook { .. } + | Resource::Event { .. } + | Resource::Card { .. } + ))), + (NS_DAV, "supported-report-set") => { + let reports: &[&str] = match resource { + Resource::Calendar { .. } => &["", ""], + Resource::AddressBook { .. } => { + &["", ""] + } + _ => &[], + }; + let inner: String = reports + .iter() + .map(|r| format!("{r}")) + .collect(); + Some(format!("{inner}")) + } + (NS_DAV, "getetag") => match resource { + Resource::Event { etag, .. } | Resource::Card { etag, .. } => { + Some(format!("{}", escape(etag))) + } + _ => None, + }, + (NS_DAV, "getcontenttype") => match resource { + Resource::Event { .. } => Some(format!( + "{ICAL_CONTENT_TYPE}; component=vevent" + )), + Resource::Card { .. } => Some(format!( + "{VCARD_CONTENT_TYPE}" + )), + _ => None, + }, + (NS_DAV, "getcontentlength") => match resource { + Resource::Event { body, .. } | Resource::Card { body, .. } => Some(format!( + "{}", + body.len() + )), + _ => None, + }, + (NS_DAV, "getlastmodified") => { + let stamp = match resource { + Resource::Event { item, .. } => http_date(&item.event.updated_at), + Resource::Card { item, .. } => http_date(&item.contact.updated_at), + _ => None, + }?; + Some(format!("{stamp}")) + } + (NS_CS, "getctag") => match resource { + Resource::Calendar { ctag: Some(tag) } | Resource::AddressBook { ctag: Some(tag) } => { + Some(format!("{}", escape(tag))) + } + _ => None, + }, + // ---- principal + (NS_CALDAV, "calendar-home-set") if matches!(resource, Resource::Principal) => { + Some(href_prop("c:calendar-home-set", &ctx.calendar_home())) + } + (NS_CARDDAV, "addressbook-home-set") if matches!(resource, Resource::Principal) => { + Some(href_prop("cr:addressbook-home-set", &ctx.addressbook_home())) + } + (NS_CALDAV, "calendar-user-address-set") if matches!(resource, Resource::Principal) => { + let inner = if ctx.login.contains('@') { + format!("mailto:{}", escape(&ctx.login)) + } else { + String::new() + }; + Some(format!( + "{inner}{}", + escape(&ctx.principal()) + )) + } + (NS_DAV, "principal-address") | (NS_DAV, "alternate-URI-set") + if matches!(resource, Resource::Principal) => + { + Some(QName::new(ns, name).empty_element()) + } + // ---- calendar collection + (NS_CALDAV, "supported-calendar-component-set") + if matches!(resource, Resource::Calendar { .. }) => + { + Some("".into()) + } + (NS_CALDAV, "supported-calendar-data") if matches!(resource, Resource::Calendar { .. }) => { + Some("".into()) + } + (NS_CALDAV, "calendar-description") if matches!(resource, Resource::Calendar { .. }) => { + Some(format!( + "{}", + escape(&ctx.calendar_name) + )) + } + (NS_CALDAV, "max-resource-size") if matches!(resource, Resource::Calendar { .. }) => { + Some(format!("{}", ctx.max_size)) + } + (NS_APPLE, "calendar-color") if matches!(resource, Resource::Calendar { .. }) => { + Some("#6F9C86FF".into()) + } + (NS_APPLE, "calendar-order") if matches!(resource, Resource::Calendar { .. }) => { + Some("1".into()) + } + (NS_CALDAV, "calendar-data") => match resource { + Resource::Event { body, .. } => Some(format!( + "{}", + escape(body) + )), + _ => None, + }, + // ---- address book collection + (NS_CARDDAV, "supported-address-data") + if matches!(resource, Resource::AddressBook { .. }) => + { + Some("".into()) + } + (NS_CARDDAV, "addressbook-description") + if matches!(resource, Resource::AddressBook { .. }) => + { + Some(format!( + "{}", + escape(&ctx.addressbook_name) + )) + } + (NS_CARDDAV, "max-resource-size") if matches!(resource, Resource::AddressBook { .. }) => { + Some(format!("{}", ctx.max_size)) + } + (NS_CARDDAV, "address-data") => match resource { + Resource::Card { body, .. } => Some(format!( + "{}", + escape(body) + )), + _ => None, + }, + _ => None, + } +} + +fn add_response(ms: &mut MultiStatus, ctx: &Ctx, resource: &Resource, request: &PropRequest) { + let href = resource.href(ctx); + match request { + PropRequest::Props(props) => { + let mut found = Vec::new(); + let mut missing = Vec::new(); + for prop in props { + match property(ctx, resource, prop) { + Some(value) => found.push(value), + None => missing.push(prop.clone()), + } + } + ms.propstat(&href, &found, &missing); + } + PropRequest::AllProp => { + let found: Vec = ALLPROP + .iter() + .filter_map(|(ns, name)| property(ctx, resource, &QName::new(ns, name))) + .collect(); + ms.propstat(&href, &found, &[]); + } + PropRequest::PropName => { + let found: Vec = ALLPROP + .iter() + .map(|(ns, name)| QName::new(ns, name)) + .filter(|q| property(ctx, resource, q).is_some()) + .map(|q| q.empty_element()) + .collect(); + ms.propstat(&href, &found, &[]); + } + } +} + +// ------------------------------------------------------------- PROPFIND + +fn propfind( + conn: &Connection, + ctx: &Ctx, + target: &Target, + request: &Request, +) -> Result { + let parsed = match xml::parse(&request.body) { + Ok(parsed) => parsed, + Err(error) => return Ok(Reply::text(StatusCode::BAD_REQUEST, &format!("{error}\n"))), + }; + if let Some(root) = &parsed + && !root.is(NS_DAV, "propfind") + { + return Ok(Reply::text( + StatusCode::BAD_REQUEST, + "expected DAV:propfind\n", + )); + } + let props = xml::prop_request(parsed.as_ref()); + let depth_one = !matches!(request.depth.as_deref(), Some("0")); + let wants_ctag = wants(&props, NS_CS, "getctag"); + + let mut resources: Vec = Vec::new(); + match target { + Target::Root => { + resources.push(Resource::Root); + if depth_one { + resources.extend([ + Resource::Principal, + Resource::CalendarHome, + Resource::AddressBookHome, + ]); + } + } + Target::Principal => resources.push(Resource::Principal), + Target::CalendarHome => { + resources.push(Resource::CalendarHome); + if depth_one { + let ctag = if wants_ctag { + Some(tag_of(&load_events(conn)?)) + } else { + None + }; + resources.push(Resource::Calendar { ctag }); + } + } + Target::AddressBookHome => { + resources.push(Resource::AddressBookHome); + if depth_one { + let ctag = if wants_ctag { + Some(tag_of(&load_cards(conn)?)) + } else { + None + }; + resources.push(Resource::AddressBook { ctag }); + } + } + Target::Calendar => { + let children = if depth_one || wants_ctag { + load_events(conn)? + } else { + Vec::new() + }; + let ctag = wants_ctag.then(|| tag_of(&children)); + resources.push(Resource::Calendar { ctag }); + if depth_one { + resources.extend(children); + } + } + Target::AddressBook => { + let children = if depth_one || wants_ctag { + load_cards(conn)? + } else { + Vec::new() + }; + let ctag = wants_ctag.then(|| tag_of(&children)); + resources.push(Resource::AddressBook { ctag }); + if depth_one { + resources.extend(children); + } + } + Target::Event(name) => match crate::db::dav::find_event_by_name(conn, name)? { + Some(item) => resources.push(Resource::event(item)), + None => return Ok(Reply::status(StatusCode::NOT_FOUND)), + }, + Target::Card(name) => match crate::db::dav::find_contact_by_name(conn, name)? { + Some(item) => resources.push(Resource::card(item)), + None => return Ok(Reply::status(StatusCode::NOT_FOUND)), + }, + Target::Forbidden | Target::NotFound => return Ok(Reply::status(StatusCode::NOT_FOUND)), + } + + let mut ms = MultiStatus::new(); + for resource in &resources { + add_response(&mut ms, ctx, resource, &props); + } + Ok(Reply::multistatus(ms)) +} + +// --------------------------------------------------------------- REPORT + +fn unsupported_report() -> Reply { + Reply::precondition(StatusCode::FORBIDDEN, "") +} + +fn report( + conn: &Connection, + ctx: &Ctx, + target: &Target, + request: &Request, +) -> Result { + let root = match xml::parse(&request.body) { + Ok(Some(root)) => root, + Ok(None) => return Ok(Reply::text(StatusCode::BAD_REQUEST, "empty REPORT body\n")), + Err(error) => return Ok(Reply::text(StatusCode::BAD_REQUEST, &format!("{error}\n"))), + }; + let props = xml::prop_request(Some(&root)); + let mut ms = MultiStatus::new(); + + match (root.ns.as_str(), root.name.as_str(), target) { + (NS_CALDAV, "calendar-multiget", Target::Calendar | Target::Event(_)) => { + for href in root.children_named(NS_DAV, "href") { + let shown = href.text.trim().to_string(); + let found = match href_name(&shown) { + Some(name) => crate::db::dav::find_event_by_name(conn, &name)?, + None => None, + }; + match found { + Some(item) => add_response(&mut ms, ctx, &Resource::event(item), &props), + None => ms.status(&shown, "404 Not Found"), + } + } + } + (NS_CALDAV, "calendar-query", Target::Calendar) => { + let Some(filter) = CalendarFilter::from_report(&root) else { + return Ok(Reply::precondition( + StatusCode::FORBIDDEN, + "", + )); + }; + if filter.events { + for item in crate::db::dav::list_events(conn)? { + if ical::overlaps(&item.event, filter.start, filter.end) { + add_response(&mut ms, ctx, &Resource::event(item), &props); + } + } + } + } + (NS_CARDDAV, "addressbook-multiget", Target::AddressBook | Target::Card(_)) => { + for href in root.children_named(NS_DAV, "href") { + let shown = href.text.trim().to_string(); + let found = match href_name(&shown) { + Some(name) => crate::db::dav::find_contact_by_name(conn, &name)?, + None => None, + }; + match found { + Some(item) => add_response(&mut ms, ctx, &Resource::card(item), &props), + None => ms.status(&shown, "404 Not Found"), + } + } + } + (NS_CARDDAV, "addressbook-query", Target::AddressBook) => { + let filter = root.child(NS_CARDDAV, "filter"); + let limit = root + .child(NS_CARDDAV, "limit") + .and_then(|l| l.child(NS_CARDDAV, "nresults")) + .and_then(|n| n.text.trim().parse::().ok()) + .unwrap_or(usize::MAX); + let mut count = 0usize; + for item in crate::db::dav::list_contacts(conn)? { + let resource = Resource::card(item); + let Resource::Card { body, .. } = &resource else { + continue; + }; + if vcard::matches_filter(body, filter) { + if count >= limit { + break; + } + add_response(&mut ms, ctx, &resource, &props); + count += 1; + } + } + } + _ => return Ok(unsupported_report()), + } + Ok(Reply::multistatus(ms)) +} + +/// What a `calendar-query` asks for. +struct CalendarFilter { + events: bool, + start: Option, + end: Option, +} + +impl CalendarFilter { + fn from_report(root: &Element) -> Option { + let all = Self { + events: true, + start: None, + end: None, + }; + let Some(filter) = root.child(NS_CALDAV, "filter") else { + return Some(all); + }; + let calendar = filter.child(NS_CALDAV, "comp-filter")?; + if !calendar + .attr("name") + .is_some_and(|n| n.eq_ignore_ascii_case("VCALENDAR")) + { + return None; + } + let Some(component) = calendar.child(NS_CALDAV, "comp-filter") else { + return Some(all); + }; + let name = component.attr("name").unwrap_or("").to_ascii_uppercase(); + if name != "VEVENT" || component.child(NS_CALDAV, "is-not-defined").is_some() { + return Some(Self { + events: false, + start: None, + end: None, + }); + } + let (start, end) = match component.child(NS_CALDAV, "time-range") { + Some(range) => ( + range.attr("start").and_then(ical::parse_range_bound), + range.attr("end").and_then(ical::parse_range_bound), + ), + None => (None, None), + }; + Some(Self { + events: true, + start, + end, + }) + } +} + +// ------------------------------------------------------------------ GET + +fn get(conn: &Connection, ctx: &Ctx, target: &Target, request: &Request) -> Result { + let (body, etag, content_type, updated) = match target { + Target::Event(name) => match crate::db::dav::find_event_by_name(conn, name)? { + Some(item) => { + let body = item.body(); + let etag = etag_of(&body); + (body, etag, ICAL_CONTENT_TYPE, item.event.updated_at) + } + None => return Ok(Reply::status(StatusCode::NOT_FOUND)), + }, + Target::Card(name) => match crate::db::dav::find_contact_by_name(conn, name)? { + Some(item) => { + let body = item.body(); + let etag = etag_of(&body); + (body, etag, VCARD_CONTENT_TYPE, item.contact.updated_at) + } + None => return Ok(Reply::status(StatusCode::NOT_FOUND)), + }, + _ => { + return Ok(Reply::text( + StatusCode::OK, + &format!( + "RustBox CalDAV/CardDAV server.\nCalendar: {}\nAddress book: {}\n", + ctx.calendar(), + ctx.addressbook() + ), + )); + } + }; + if let Some(header) = &request.if_none_match + && etag_matches(header, &etag) + { + return Ok(Reply::status(StatusCode::NOT_MODIFIED).with_header(header::ETAG, etag)); + } + let mut reply = Reply { + status: StatusCode::OK, + content_type: Some(content_type), + headers: vec![(header::ETAG, etag)], + body, + }; + if let Some(date) = http_date(&updated) { + reply.headers.push((header::LAST_MODIFIED, date)); + } + Ok(reply) +} + +// ------------------------------------------------------------------ PUT + +/// Evaluate `If-Match` / `If-None-Match` against the current ETag (if any). +fn preconditions_fail(request: &Request, current: Option<&str>) -> bool { + if let Some(header) = &request.if_none_match + && let Some(etag) = current + && etag_matches(header, etag) + { + return true; + } + if let Some(header) = &request.if_match { + return match current { + None => true, + Some(etag) => !etag_matches(header, etag), + }; + } + false +} + +fn put(conn: &Connection, ctx: &Ctx, target: &Target, request: &Request) -> Result { + let (name, is_event) = match target { + Target::Event(name) => (name, true), + Target::Card(name) => (name, false), + _ => { + return Ok(Reply::status(StatusCode::METHOD_NOT_ALLOWED) + .with_header(header::ALLOW, "OPTIONS, PROPFIND, REPORT".into())); + } + }; + if !valid_resource_name(name) { + return Ok(Reply::text( + StatusCode::BAD_REQUEST, + "invalid resource name\n", + )); + } + if request.body.len() > ctx.max_size { + let inner = if is_event { + "" + } else { + "" + }; + return Ok(Reply::precondition(StatusCode::FORBIDDEN, inner)); + } + let Ok(body) = std::str::from_utf8(&request.body) else { + return Ok(Reply::text(StatusCode::BAD_REQUEST, "body must be UTF-8\n")); + }; + if is_event { + put_event(conn, ctx, name, body, request) + } else { + put_card(conn, name, body, request) + } +} + +fn put_event( + conn: &Connection, + ctx: &Ctx, + name: &str, + body: &str, + request: &Request, +) -> Result { + let existing = crate::db::dav::find_event_by_name(conn, name)?; + let current_etag = existing.as_ref().map(|e| etag_of(&e.body())); + if preconditions_fail(request, current_etag.as_deref()) { + return Ok(Reply::status(StatusCode::PRECONDITION_FAILED)); + } + let parsed = match ical::parse_object(body) { + Ok(parsed) => parsed, + Err(ical::IcalError::UnsupportedComponent) => { + return Ok(Reply::precondition( + StatusCode::FORBIDDEN, + "", + )); + } + Err(ical::IcalError::Invalid(reason)) => { + tracing::debug!(%reason, "DAV: rejected calendar object"); + return Ok(Reply::precondition( + StatusCode::FORBIDDEN, + "", + )); + } + }; + + // RFC 4791 5.3.2.1: a UID may only be used by one resource of the collection. + // An event created in the webmail (e.g. from an e-mail invitation) is adopted + // instead, so accepting the invitation on a phone does not duplicate it. + let mut target_id = existing.as_ref().map(|e| e.event.id.clone()); + if let Some(other) = crate::db::dav::find_event_by_uid(conn, &parsed.uid)? { + let same_row = target_id.as_deref() == Some(other.event.id.as_str()); + if !same_row { + if target_id.is_none() && other.dav_name.is_none() { + target_id = Some(other.event.id.clone()); + } else { + let inner = format!( + "{}", + escape(&ctx.event(&other.resource_name())) + ); + return Ok(Reply::precondition(StatusCode::FORBIDDEN, &inner)); + } + } + } + + let data = parsed.fields.to_create_event(&parsed.uid, "caldav"); + let created = existing.is_none(); + crate::db::dav::save_event(conn, target_id.as_deref(), &data, name, body)?; + let status = if created { + StatusCode::CREATED + } else { + StatusCode::NO_CONTENT + }; + Ok(Reply::status(status).with_header(header::ETAG, etag_of(body))) +} + +fn put_card(conn: &Connection, name: &str, body: &str, request: &Request) -> Result { + let existing = crate::db::dav::find_contact_by_name(conn, name)?; + let current_etag = existing.as_ref().map(|c| etag_of(&c.body())); + if preconditions_fail(request, current_etag.as_deref()) { + return Ok(Reply::status(StatusCode::PRECONDITION_FAILED)); + } + let parsed = match vcard::parse_card(body) { + Ok(parsed) => parsed, + Err(reason) => { + tracing::debug!(%reason, "DAV: rejected vCard"); + return Ok(Reply::precondition( + StatusCode::FORBIDDEN, + "", + )); + } + }; + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let f = parsed.fields; + let previous = existing.as_ref().map(|e| &e.contact); + let contact = crate::db::contacts::Contact { + id: previous + .map(|c| c.id.clone()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + email: f.email, + name: f.name, + company: f.company, + notes: f.notes, + phone: f.phone, + job_title: f.job_title, + address: f.address, + website: f.website, + birthday: f.birthday, + emails: f.emails, + phones: f.phones, + addresses: f.addresses, + is_favorite: previous.is_some_and(|c| c.is_favorite), + last_contacted: previous.and_then(|c| c.last_contacted.clone()), + contact_count: previous.map(|c| c.contact_count).unwrap_or(0), + source: previous + .map(|c| c.source.clone()) + .unwrap_or_else(|| "carddav".into()), + created_at: previous + .map(|c| c.created_at.clone()) + .unwrap_or_else(|| now.clone()), + updated_at: now, + has_photo: false, + }; + crate::db::dav::save_contact(conn, &contact, name, body)?; + if let Some((content_type, bytes)) = parsed.photo + && let Err(error) = + crate::db::contacts::set_contact_photo(conn, &contact.id, &content_type, &bytes) + { + tracing::warn!(%error, "DAV: failed to store contact photo"); + } + let status = if existing.is_none() { + StatusCode::CREATED + } else { + StatusCode::NO_CONTENT + }; + Ok(Reply::status(status).with_header(header::ETAG, etag_of(body))) +} + +// --------------------------------------------------------------- DELETE + +fn delete(conn: &Connection, target: &Target, request: &Request) -> Result { + match target { + Target::Event(name) => { + let Some(item) = crate::db::dav::find_event_by_name(conn, name)? else { + return Ok(Reply::status(StatusCode::NOT_FOUND)); + }; + if preconditions_fail(request, Some(&etag_of(&item.body()))) { + return Ok(Reply::status(StatusCode::PRECONDITION_FAILED)); + } + crate::db::calendar::delete_event(conn, &item.event.id)?; + Ok(Reply::status(StatusCode::NO_CONTENT)) + } + Target::Card(name) => { + let Some(item) = crate::db::dav::find_contact_by_name(conn, name)? else { + return Ok(Reply::status(StatusCode::NOT_FOUND)); + }; + if preconditions_fail(request, Some(&etag_of(&item.body()))) { + return Ok(Reply::status(StatusCode::PRECONDITION_FAILED)); + } + crate::db::contacts::delete_contact(conn, &item.contact.id)?; + Ok(Reply::status(StatusCode::NO_CONTENT)) + } + _ => Ok(Reply::text( + StatusCode::FORBIDDEN, + "Collections cannot be deleted\n", + )), + } +} + +// ------------------------------------------------------------ PROPPATCH + +fn proppatch(target: &Target, request: &Request, ctx: &Ctx) -> Result { + let root = match xml::parse(&request.body) { + Ok(Some(root)) if root.is(NS_DAV, "propertyupdate") => root, + Ok(_) => { + return Ok(Reply::text( + StatusCode::BAD_REQUEST, + "expected propertyupdate\n", + )); + } + Err(error) => return Ok(Reply::text(StatusCode::BAD_REQUEST, &format!("{error}\n"))), + }; + let names: Vec = root + .children + .iter() + .filter(|c| c.is(NS_DAV, "set") || c.is(NS_DAV, "remove")) + .filter_map(|c| c.child(NS_DAV, "prop")) + .flat_map(|p| p.children.iter().map(Element::qname)) + .collect(); + let href = match target { + Target::Calendar => ctx.calendar(), + Target::AddressBook => ctx.addressbook(), + Target::CalendarHome => ctx.calendar_home(), + Target::AddressBookHome => ctx.addressbook_home(), + Target::Principal => ctx.principal(), + Target::Event(name) => ctx.event(name), + Target::Card(name) => ctx.card(name), + _ => format!("{}/", ctx.root), + }; + // Collection properties are fixed (one calendar / address book per account). + let mut ms = MultiStatus::new(); + ms.propstat_status(&href, &names, "403 Forbidden"); + Ok(Reply::multistatus(ms)) +} + +#[cfg(test)] +mod tests; diff --git a/src/dav/tests.rs b/src/dav/tests.rs new file mode 100644 index 0000000..60da20c --- /dev/null +++ b/src/dav/tests.rs @@ -0,0 +1,655 @@ +//! End-to-end tests of the DAV endpoint. The request bodies are the ones sent by +//! the ActivePush CalDAV/CardDAV client (`src/backend/dav.rs`) plus a few generic +//! client requests (well-known discovery, principal lookup). + +use super::*; +use axum::body::Body; +use axum::http::Request as HttpRequest; +use http_body_util::BodyExt; +use tempfile::TempDir; +use tower::ServiceExt; + +const LOGIN: &str = "jan@example.com"; +const PASSWORD: &str = "secret"; + +struct TestApp { + app: Router, + _dir: TempDir, + data_dir: String, + root: String, +} + +fn test_app(base_path: Option<&str>) -> TestApp { + let dir = TempDir::new().unwrap(); + let data_dir = dir.path().to_str().unwrap().to_string(); + let mut config = AppConfig { + imap_host: Some("imap.example.com".into()), + data_dir: data_dir.clone(), + base_path: base_path.map(str::to_string), + ..AppConfig::default() + }; + config.dav.enabled = true; + let config = Arc::new(config); + crate::auth::user_data::provision_user_database( + &data_dir, + &crate::auth::user_data::hash_email(LOGIN), + ) + .unwrap(); + // Credentials are pre-validated so no IMAP server is needed. + let cache = Arc::new(DavAuthCache::default()); + cache.remember(DavAuthCache::key(LOGIN, PASSWORD), Duration::from_secs(600)); + let inner = routes_with_cache(&config, cache); + let app = match config.normalized_base_path() { + "/" => inner, + base => Router::new().nest(base, inner), + } + .merge(well_known_routes()) + .layer(Extension(config.clone())); + TestApp { + app, + _dir: dir, + root: dav_root(&config), + data_dir, + } +} + +impl TestApp { + fn conn(&self) -> Connection { + crate::db::pool::open_user_db(&self.data_dir, &crate::auth::user_data::hash_email(LOGIN)) + .unwrap() + } + + async fn send( + &self, + method: &str, + path: &str, + headers: &[(&str, &str)], + body: &str, + ) -> (StatusCode, HeaderMap, String) { + self.send_as(method, path, headers, body, Some((LOGIN, PASSWORD))) + .await + } + + async fn send_as( + &self, + method: &str, + path: &str, + headers: &[(&str, &str)], + body: &str, + credentials: Option<(&str, &str)>, + ) -> (StatusCode, HeaderMap, String) { + let uri = if path.starts_with("/.well-known") { + path.to_string() + } else { + format!("{}{path}", self.root) + }; + let mut builder = HttpRequest::builder().method(method).uri(uri); + if let Some((user, pass)) = credentials { + let token = base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}")); + builder = builder.header("authorization", format!("Basic {token}")); + } + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let response = self + .app + .clone() + .oneshot(builder.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + headers, + String::from_utf8_lossy(&bytes).into_owned(), + ) + } +} + +const XML: (&str, &str) = ("content-type", "application/xml; charset=utf-8"); + +// ---- request bodies copied from ActivePush src/backend/dav.rs +const AP_CHECK: &str = r#""#; +const AP_COLLECTIONS: &str = r#" + + +"#; +const AP_CTAG: &str = r#""#; +const AP_LIST_CARDS: &str = r#""#; + +fn ap_calendar_query(since: Option<&str>) -> String { + let range = since + .map(|s| format!("")) + .unwrap_or_default(); + format!( + r#" + + + {range} +"# + ) +} + +fn ap_multiget(hrefs: &[String], cards: bool) -> String { + let hs: String = hrefs + .iter() + .map(|h| format!("{}", xml::escape(h))) + .collect(); + if cards { + format!( + r#"{hs}"# + ) + } else { + format!( + r#"{hs}"# + ) + } +} + +fn ap_search_cards(text: &str) -> String { + format!( + r#" + + + + {text} + {text} + {text} + +"# + ) +} + +/// Pull `value` values out of a multistatus body (namespace-agnostic). +fn values(body: &str, local: &str) -> Vec { + let doc = xml::parse(body.as_bytes()).unwrap().unwrap(); + let mut out = Vec::new(); + fn walk(e: &Element, local: &str, out: &mut Vec) { + if e.name == local { + out.push(e.text.clone()); + } + for c in &e.children { + walk(c, local, out); + } + } + walk(&doc, local, &mut out); + out +} + +fn ctag(xml: &str) -> String { + values(xml, "getctag") + .into_iter() + .find(|v| !v.is_empty()) + .unwrap_or_default() +} + +fn event_ics(uid: &str, summary: &str) -> String { + format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//ActivePush//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\ +DTSTAMP:20261101T000000Z\r\nDTSTART;TZID=Europe/Warsaw:20261110T090000\r\nDTEND;TZID=Europe/Warsaw:20261110T100000\r\n\ +SUMMARY:{summary}\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + ) +} + +#[tokio::test] +async fn options_is_public_and_everything_else_needs_basic_auth() { + let t = test_app(None); + let (status, headers, _) = t.send_as("OPTIONS", "/", &[], "", None).await; + assert_eq!(status, StatusCode::OK); + assert!(headers["dav"].to_str().unwrap().contains("calendar-access")); + let (status, headers, _) = t.send_as("PROPFIND", "/", &[], "", None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(headers.contains_key("www-authenticate")); + // Another account's collection is refused even with valid credentials. + let (status, _, _) = t + .send( + "PROPFIND", + "/calendars/someone@example.com/", + &[("depth", "0")], + AP_CHECK, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn discovery_from_well_known_to_calendar_home() { + let t = test_app(Some("/mail")); + let (status, headers, _) = t.send("PROPFIND", "/.well-known/caldav", &[], "").await; + assert_eq!(status, StatusCode::MOVED_PERMANENTLY); + assert_eq!(headers["location"], "/mail/dav/"); + + let body = + r#""#; + let (status, _, xml) = t.send("PROPFIND", "/", &[("depth", "0"), XML], body).await; + assert_eq!(status, StatusCode::MULTI_STATUS); + assert_eq!( + values(&xml, "href")[1], + "/mail/dav/principals/jan@example.com/" + ); + + let body = r#""#; + let (status, _, xml) = t + .send( + "PROPFIND", + "/principals/jan@example.com/", + &[("depth", "0"), XML], + body, + ) + .await; + assert_eq!(status, StatusCode::MULTI_STATUS); + let hrefs = values(&xml, "href"); + assert!(hrefs.contains(&"/mail/dav/calendars/jan@example.com/".to_string())); + assert!(hrefs.contains(&"/mail/dav/addressbooks/jan@example.com/".to_string())); + assert!(hrefs.contains(&"mailto:jan@example.com".to_string())); +} + +#[tokio::test] +async fn activepush_calendar_sync_cycle() { + let t = test_app(None); + let home = "/calendars/jan@example.com/"; + let collection = "/dav/calendars/jan@example.com/default/"; + + // check() + let (status, _, _) = t + .send("PROPFIND", home, &[("depth", "0"), XML], AP_CHECK) + .await; + assert_eq!(status.as_u16(), 207); + + // An event created in the webmail before the phone syncs. + let webmail: crate::db::calendar::CreateEvent = serde_json::from_value(serde_json::json!({ + "title": "Webmail meeting", + "start_time": "2026-11-12T13:00:00.000Z", + "end_time": "2026-11-12T14:00:00.000Z", + "reminder_minutes": 10 + })) + .unwrap(); + let webmail = crate::db::calendar::create_event(&t.conn(), &webmail).unwrap(); + + // collections(): home Depth 1 lists exactly one VEVENT calendar with a ctag. + let (status, _, xml) = t + .send("PROPFIND", home, &[("depth", "1"), XML], AP_COLLECTIONS) + .await; + assert_eq!(status.as_u16(), 207); + assert!(xml.contains("")); + assert!(xml.contains(r#""#)); + assert!(values(&xml, "href").contains(&collection.to_string())); + let ctag0 = ctag(&xml); + assert!(ctag0.starts_with("rustbox-")); + + // list(): calendar-query with a time-range returns href + etag. + let (status, _, xml) = t + .send( + "REPORT", + "/calendars/jan@example.com/default/", + &[("depth", "1"), XML], + &ap_calendar_query(Some("20261101T000000Z")), + ) + .await; + assert_eq!(status.as_u16(), 207); + let hrefs = values(&xml, "href"); + assert_eq!(hrefs, vec![format!("{collection}{}.ics", webmail.id)]); + assert_eq!(values(&xml, "getetag").len(), 1); + // Past range excludes it. + let (_, _, xml) = t + .send( + "REPORT", + "/calendars/jan@example.com/default/", + &[("depth", "1"), XML], + &ap_calendar_query(Some("20261201T000000Z")), + ) + .await; + assert!(values(&xml, "href").is_empty()); + + // multiget(): the webmail event as iCalendar. + let (_, _, xml) = t + .send( + "REPORT", + "/calendars/jan@example.com/default/", + &[("depth", "1"), XML], + &ap_multiget(&hrefs, false), + ) + .await; + let data = values(&xml, "calendar-data"); + assert_eq!(data.len(), 1); + assert!(data[0].contains("SUMMARY:Webmail meeting")); + assert!(data[0].contains("TRIGGER:-PT10M")); + + // put(): new event from the phone (If-None-Match: *). + let name = "4f9e7c1a-0000-4000-8000-000000000001.ics"; + let path = format!("/calendars/jan@example.com/default/{name}"); + let ics = event_ics("phone-uid-1", "Dentist"); + let (status, headers, _) = t + .send( + "PUT", + &path, + &[ + ("content-type", "text/calendar; charset=utf-8"), + ("if-none-match", "*"), + ], + &ics, + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let etag1 = headers["etag"].to_str().unwrap().to_string(); + // Creating it again must fail. + let (status, _, _) = t.send("PUT", &path, &[("if-none-match", "*")], &ics).await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED); + + // The webmail sees the phone event, converted from Europe/Warsaw to UTC. + let stored = crate::db::calendar::find_event_by_source_uid(&t.conn(), "phone-uid-1") + .unwrap() + .unwrap(); + assert_eq!(stored.title, "Dentist"); + assert_eq!(stored.start_time, "2026-11-10T08:00:00Z"); + assert_eq!(stored.reminder_minutes, Some(15)); + assert_eq!(stored.source, "caldav"); + + // ctag() changed. + let (_, _, xml) = t + .send( + "PROPFIND", + "/calendars/jan@example.com/default/", + &[("depth", "0"), XML], + AP_CTAG, + ) + .await; + let ctag1 = ctag(&xml); + assert_ne!(ctag0, ctag1); + + // get(): byte-identical body and the same ETag. + let (status, headers, body) = t.send("GET", &path, &[], "").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, ics); + assert_eq!(headers["etag"].to_str().unwrap(), etag1); + + // Update with a stale ETag fails, with the current one succeeds. + let updated = event_ics("phone-uid-1", "Dentist (moved)"); + let (status, _, _) = t + .send("PUT", &path, &[("if-match", "\"stale\"")], &updated) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED); + let (status, headers, _) = t + .send("PUT", &path, &[("if-match", etag1.as_str())], &updated) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + let etag2 = headers["etag"].to_str().unwrap().to_string(); + assert_ne!(etag1, etag2); + + // A webmail edit is visible to the phone, keeping the time zone. + let edit: crate::db::calendar::UpdateEvent = + serde_json::from_value(serde_json::json!({"location": "Clinic"})).unwrap(); + crate::db::calendar::update_event(&t.conn(), &stored.id, &edit).unwrap(); + let (_, headers, body) = t.send("GET", &path, &[], "").await; + assert!(body.contains("LOCATION:Clinic\r\n")); + assert!(body.contains("DTSTART;TZID=Europe/Warsaw:20261110T090000\r\n")); + assert_ne!(headers["etag"].to_str().unwrap(), etag2); + + // VTODO is not accepted (the calendar only advertises VEVENT). + let todo = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:x\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + let (status, _, body) = t + .send( + "PUT", + "/calendars/jan@example.com/default/t1.ics", + &[], + todo, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert!(body.contains("supported-calendar-component")); + + // A second resource with the same UID is a conflict. + let (status, _, body) = t + .send( + "PUT", + "/calendars/jan@example.com/default/dup.ics", + &[], + &ics, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert!(body.contains("no-uid-conflict")); + + // delete() + let (status, _, _) = t.send("DELETE", &path, &[], "").await; + assert_eq!(status, StatusCode::NO_CONTENT); + let (status, _, _) = t.send("GET", &path, &[], "").await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert!( + crate::db::calendar::find_event_by_source_uid(&t.conn(), "phone-uid-1") + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn accepting_an_invitation_on_the_phone_adopts_the_webmail_event() { + let t = test_app(None); + let invitation: crate::db::calendar::CreateEvent = serde_json::from_value(serde_json::json!({ + "title": "Board meeting", + "start_time": "2026-11-20T09:00:00Z", + "end_time": "2026-11-20T10:00:00Z", + "source": "invitation", + "source_uid": "inv-42@example.com" + })) + .unwrap(); + let original = crate::db::calendar::create_event(&t.conn(), &invitation).unwrap(); + + let (status, _, _) = t + .send( + "PUT", + "/calendars/jan@example.com/default/accepted.ics", + &[("if-none-match", "*")], + &event_ics("inv-42@example.com", "Board meeting"), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let all = crate::db::calendar::list_all_events(&t.conn()).unwrap(); + assert_eq!(all.len(), 1, "no duplicate event"); + assert_eq!(all[0].id, original.id); + assert_eq!(all[0].source, "invitation"); +} + +#[tokio::test] +async fn activepush_contacts_sync_cycle() { + let t = test_app(None); + let home = "/addressbooks/jan@example.com/"; + let book = "/addressbooks/jan@example.com/default/"; + + let (status, _, xml) = t + .send("PROPFIND", home, &[("depth", "1"), XML], AP_COLLECTIONS) + .await; + assert_eq!(status.as_u16(), 207); + assert!(xml.contains("")); + assert!(values(&xml, "href").contains(&format!("/dav{book}"))); + + // A webmail contact is listed through PROPFIND Depth 1 (list() without comp). + let webmail = crate::db::contacts::Contact { + id: "w1".into(), + email: "boss@example.com".into(), + name: "The Boss".into(), + company: String::new(), + notes: String::new(), + phone: String::new(), + job_title: String::new(), + address: String::new(), + website: String::new(), + birthday: String::new(), + emails: vec![], + phones: vec![], + addresses: vec![], + is_favorite: true, + last_contacted: None, + contact_count: 3, + source: "manual".into(), + created_at: "2026-11-01 00:00:00".into(), + updated_at: "2026-11-01 00:00:00".into(), + has_photo: false, + }; + crate::db::contacts::upsert_contact(&t.conn(), &webmail).unwrap(); + let (_, _, xml) = t + .send("PROPFIND", book, &[("depth", "1"), XML], AP_LIST_CARDS) + .await; + let hrefs = values(&xml, "href"); + assert_eq!(hrefs.len(), 2, "collection + one card: {xml}"); + assert_eq!(hrefs[1], "/dav/addressbooks/jan@example.com/default/w1.vcf"); + assert!(xml.contains("")); + + // A phone contact without an e-mail address. + let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:p1\r\nFN:Plumber Pete\r\nN:Pete;Plumber;;;\r\nTEL;TYPE=CELL:+48 600 100 200\r\nEND:VCARD\r\n"; + let (status, headers, _) = t + .send( + "PUT", + &format!("{book}p1.vcf"), + &[ + ("content-type", "text/vcard; charset=utf-8"), + ("if-none-match", "*"), + ], + card, + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert!(headers.contains_key("etag")); + let stored = crate::db::dav::find_contact_by_name(&t.conn(), "p1.vcf") + .unwrap() + .unwrap(); + assert_eq!(stored.contact.name, "Plumber Pete"); + assert_eq!(stored.contact.phone, "+48 600 100 200"); + assert_eq!(stored.contact.email, ""); + + // multiget() of both. + let hrefs = vec![ + format!("/dav{book}w1.vcf"), + format!("/dav{book}p1.vcf"), + format!("/dav{book}missing.vcf"), + ]; + let (_, _, xml) = t + .send( + "REPORT", + book, + &[("depth", "1"), XML], + &ap_multiget(&hrefs, true), + ) + .await; + let data = values(&xml, "address-data"); + assert_eq!(data.len(), 2); + assert!(data[0].contains("FN:The Boss")); + assert_eq!(data[1], card); + assert!(xml.contains("404 Not Found")); + + // search_cards() (GAL search). + let (status, _, xml) = t + .send( + "REPORT", + book, + &[("depth", "1"), XML], + &ap_search_cards("plumb"), + ) + .await; + assert_eq!(status.as_u16(), 207); + let found = values(&xml, "address-data"); + assert_eq!(found.len(), 1); + assert!(found[0].contains("Plumber Pete")); + + // Webmail edit of the phone contact is patched into the stored vCard. + let mut edited = stored.contact.clone(); + edited.email = "pete@plumbing.example".into(); + crate::db::contacts::upsert_contact(&t.conn(), &edited).unwrap(); + let (_, _, body) = t.send("GET", &format!("{book}p1.vcf"), &[], "").await; + assert!(body.contains("EMAIL;TYPE=INTERNET,PREF:pete@plumbing.example\r\n")); + assert!(body.contains("TEL;TYPE=CELL:+48 600 100 200\r\n")); + + // Updating from the phone keeps webmail-only metadata (favourite, counters). + let (_, headers, _) = t.send("GET", &format!("{book}w1.vcf"), &[], "").await; + let etag = headers["etag"].to_str().unwrap().to_string(); + let boss = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:w1\r\nFN:The Big Boss\r\nEMAIL:boss@example.com\r\nEND:VCARD\r\n"; + let (status, _, _) = t + .send( + "PUT", + &format!("{book}w1.vcf"), + &[("if-match", etag.as_str())], + boss, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + let boss_row = crate::db::contacts::get_contact(&t.conn(), "w1") + .unwrap() + .unwrap(); + assert_eq!(boss_row.name, "The Big Boss"); + assert!(boss_row.is_favorite); + assert_eq!(boss_row.contact_count, 3); + + // Invalid card is rejected; delete works. + let (status, _, _) = t.send("PUT", &format!("{book}bad.vcf"), &[], "hello").await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _, _) = t.send("DELETE", &format!("{book}p1.vcf"), &[], "").await; + assert_eq!(status, StatusCode::NO_CONTENT); + assert!( + crate::db::contacts::get_contact(&t.conn(), &stored.contact.id) + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn unsupported_operations_are_refused_cleanly() { + let t = test_app(None); + let (status, _, _) = t + .send("MKCALENDAR", "/calendars/jan@example.com/work/", &[], "") + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + let sync = r#""#; + let (status, _, body) = t + .send( + "REPORT", + "/calendars/jan@example.com/default/", + &[XML], + sync, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert!(body.contains("supported-report")); + let rename = r#"Work"#; + let (status, _, body) = t + .send( + "PROPPATCH", + "/calendars/jan@example.com/default/", + &[XML], + rename, + ) + .await; + assert_eq!(status.as_u16(), 207); + assert!(body.contains("403 Forbidden")); + let (status, _, _) = t + .send( + "PROPFIND", + "/calendars/jan@example.com/other/", + &[("depth", "0")], + "", + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (status, _, _) = t + .send("DELETE", "/calendars/jan@example.com/default/", &[], "") + .await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[test] +fn etag_and_href_helpers() { + assert!(etag_matches("*", "\"a\"")); + assert!(etag_matches("W/\"a\", \"b\"", "\"a\"")); + assert!(!etag_matches("\"b\"", "\"a\"")); + assert_eq!( + href_name("https://mail.example.com/dav/calendars/u/default/a%20b.ics").as_deref(), + Some("a b.ics") + ); + assert_eq!(href_name("/dav/x/").as_deref(), Some("x")); + assert!(!valid_resource_name("../x")); + assert_eq!( + parse_target("/calendars/U@x/default/a.ics", "u@x"), + Target::Event("a.ics".into()) + ); +} diff --git a/src/dav/vcard.rs b/src/dav/vcard.rs new file mode 100644 index 0000000..b831508 --- /dev/null +++ b/src/dav/vcard.rs @@ -0,0 +1,667 @@ +//! Mapping between vCards (RFC 2426 / RFC 6350) and RustBox contacts. +//! +//! Like calendar objects, uploaded vCards are stored verbatim (`contacts.dav_data`) +//! and decoded into the webmail columns; webmail edits are patched back into the +//! stored card property by property. + +use base64::Engine as _; +use chrono::Utc; + +use super::content::{self, Line, escape_text, split_structured}; +use super::xml::{Element, NS_CARDDAV}; +use crate::db::contacts::Contact; + +pub const PRODID: &str = "-//RustBox//CardDAV//EN"; +/// Largest embedded PHOTO imported into the webmail photo store. +const MAX_PHOTO_BYTES: usize = 3 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CardFields { + pub name: String, + pub email: String, + pub emails: Vec, + pub phone: String, + pub phones: Vec, + pub company: String, + pub job_title: String, + pub notes: String, + pub address: String, + pub addresses: Vec, + pub website: String, + pub birthday: String, +} + +impl CardFields { + pub fn from_contact(contact: &Contact) -> Self { + Self { + name: contact.name.trim().to_string(), + email: contact.email.trim().to_string(), + emails: clean_list(&contact.emails, &contact.email), + phone: contact.phone.trim().to_string(), + phones: clean_list(&contact.phones, &contact.phone), + company: contact.company.trim().to_string(), + job_title: contact.job_title.trim().to_string(), + notes: contact.notes.clone(), + address: contact.address.trim().to_string(), + addresses: clean_list(&contact.addresses, &contact.address), + website: contact.website.trim().to_string(), + birthday: contact.birthday.trim().to_string(), + } + } + + fn all_emails(&self) -> Vec { + let mut all = Vec::new(); + if !self.email.is_empty() { + all.push(self.email.clone()); + } + all.extend(self.emails.iter().cloned()); + all + } + + fn all_phones(&self) -> Vec { + let mut all = Vec::new(); + if !self.phone.is_empty() { + all.push(self.phone.clone()); + } + all.extend(self.phones.iter().cloned()); + all + } + + fn all_addresses(&self) -> Vec { + let mut all = Vec::new(); + if !self.address.is_empty() { + all.push(self.address.clone()); + } + all.extend(self.addresses.iter().cloned()); + all + } + + fn display_name(&self) -> String { + [&self.name, &self.email, &self.phone, &self.company] + .into_iter() + .find(|v| !v.trim().is_empty()) + .cloned() + .unwrap_or_else(|| "Unnamed".into()) + } +} + +fn clean_list(values: &[String], primary: &str) -> Vec { + let mut out: Vec = Vec::new(); + for value in values { + let value = value.trim(); + if value.is_empty() + || value.eq_ignore_ascii_case(primary.trim()) + || out.iter().any(|v| v.eq_ignore_ascii_case(value)) + { + continue; + } + out.push(value.to_string()); + } + out +} + +#[derive(Debug)] +pub struct ParsedCard { + pub fields: CardFields, + /// Embedded photo `(content type, bytes)`, if any. + pub photo: Option<(String, Vec)>, +} + +fn card_lines(text: &str) -> Result, String> { + let mut depth = 0usize; + let mut cards = 0usize; + let mut lines = Vec::new(); + for raw in content::unfold(text) { + if let Some((begin, name)) = content::component_marker(&raw) + && name == "VCARD" + { + if begin { + depth += 1; + cards += 1; + if depth > 1 { + return Err("nested BEGIN:VCARD".into()); + } + } else { + depth = depth.checked_sub(1).ok_or("unbalanced END:VCARD")?; + } + continue; + } + if depth == 1 + && let Some(line) = content::parse_line(&raw) + { + lines.push(line); + } + } + match (cards, depth) { + (1, 0) => Ok(lines), + (0, _) => Err("no vCard found".into()), + (_, 0) => Err("an address object resource must contain exactly one vCard".into()), + _ => Err("incomplete vCard".into()), + } +} + +fn strip_scheme(value: &str, scheme: &str) -> String { + let value = value.trim(); + match value.get(..scheme.len()) { + Some(prefix) if prefix.eq_ignore_ascii_case(scheme) => { + value[scheme.len()..].trim().to_string() + } + _ => value.to_string(), + } +} + +fn is_pref(line: &Line) -> bool { + line.param_has("TYPE", "PREF") || line.param("PREF").is_some_and(|p| p.trim() == "1") +} + +/// Ordered values: preferred first, then the rest in document order, deduplicated. +fn ordered( + lines: &[&Line], + value: impl Fn(&Line) -> String, + case_insensitive: bool, +) -> Vec { + let mut sorted: Vec<&&Line> = lines.iter().filter(|l| is_pref(l)).collect(); + sorted.extend(lines.iter().filter(|l| !is_pref(l))); + let mut out: Vec = Vec::new(); + for line in sorted { + let v = value(line); + if v.is_empty() { + continue; + } + let duplicate = out.iter().any(|existing| { + if case_insensitive { + existing.eq_ignore_ascii_case(&v) + } else { + *existing == v + } + }); + if !duplicate { + out.push(v); + } + } + out +} + +fn address_text(line: &Line) -> String { + split_structured(&line.value) + .into_iter() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect::>() + .join(", ") +} + +fn photo_type(data: &[u8]) -> Option<&'static str> { + if data.starts_with(&[0xFF, 0xD8, 0xFF]) { + Some("image/jpeg") + } else if data.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { + Some("image/gif") + } else if data.len() > 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" { + Some("image/webp") + } else { + None + } +} + +fn decode_photo(line: &Line) -> Option<(String, Vec)> { + let value = line.value.trim(); + let encoded = if let Some(rest) = value.strip_prefix("data:") { + rest.split_once(',').map(|(_, data)| data)? + } else if line.param_has("ENCODING", "B") || line.param_has("ENCODING", "BASE64") { + value + } else { + return None; + }; + let cleaned: String = encoded.chars().filter(|c| !c.is_whitespace()).collect(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(cleaned.as_bytes()) + .ok()?; + if bytes.len() > MAX_PHOTO_BYTES { + return None; + } + let content_type = photo_type(&bytes)?; + Some((content_type.to_string(), bytes)) +} + +pub fn parse_card(text: &str) -> Result { + let lines = card_lines(text)?; + let named = |name: &str| -> Vec<&Line> { lines.iter().filter(|l| l.name == name).collect() }; + let first = |name: &str| -> String { + lines + .iter() + .find(|l| l.name == name) + .map(|l| l.text().trim().to_string()) + .unwrap_or_default() + }; + + let mut name = first("FN"); + if name.is_empty() + && let Some(n) = lines.iter().find(|l| l.name == "N") + { + let parts = split_structured(&n.value); + let given = parts.get(1).map(|s| s.trim()).unwrap_or(""); + let family = parts.first().map(|s| s.trim()).unwrap_or(""); + name = format!("{given} {family}").trim().to_string(); + } + + let emails = ordered( + &named("EMAIL"), + |l| strip_scheme(&l.text(), "mailto:"), + true, + ); + let phones = ordered(&named("TEL"), |l| strip_scheme(&l.text(), "tel:"), false); + let addresses = ordered(&named("ADR"), address_text, false); + let company = lines + .iter() + .find(|l| l.name == "ORG") + .map(|l| { + split_structured(&l.value) + .into_iter() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + let website = lines + .iter() + .find(|l| l.name == "URL") + .map(|l| l.value.trim().to_string()) + .unwrap_or_default(); + + let split_first = |mut values: Vec| -> (String, Vec) { + if values.is_empty() { + (String::new(), Vec::new()) + } else { + let primary = values.remove(0); + (primary, values) + } + }; + let (email, emails) = split_first(emails); + let (phone, phones) = split_first(phones); + let (address, addresses) = split_first(addresses); + + let photo = lines + .iter() + .filter(|l| l.name == "PHOTO") + .find_map(decode_photo); + + Ok(ParsedCard { + fields: CardFields { + name, + email, + emails, + phone, + phones, + company, + job_title: first("TITLE"), + notes: lines + .iter() + .find(|l| l.name == "NOTE") + .map(Line::text) + .unwrap_or_default(), + address, + addresses, + website, + birthday: first("BDAY"), + }, + photo, + }) +} + +fn name_lines(fields: &CardFields) -> Vec { + let display = fields.display_name(); + let mut words: Vec<&str> = fields.name.split_whitespace().collect(); + let family = if words.len() > 1 { + words.pop().unwrap_or_default().to_string() + } else { + String::new() + }; + let given = if fields.name.trim().is_empty() { + String::new() + } else { + words.join(" ") + }; + vec![ + format!("FN:{}", escape_text(&display)), + format!("N:{};{};;;", escape_text(&family), escape_text(&given)), + ] +} + +fn email_lines(fields: &CardFields) -> Vec { + fields + .all_emails() + .iter() + .enumerate() + .map(|(i, e)| { + let pref = if i == 0 { ",PREF" } else { "" }; + format!("EMAIL;TYPE=INTERNET{pref}:{}", escape_text(e)) + }) + .collect() +} + +fn phone_lines(fields: &CardFields) -> Vec { + fields + .all_phones() + .iter() + .enumerate() + .map(|(i, p)| { + let params = if i == 0 { + ";TYPE=VOICE,PREF" + } else { + ";TYPE=VOICE" + }; + format!("TEL{params}:{}", escape_text(p)) + }) + .collect() +} + +fn address_lines(fields: &CardFields) -> Vec { + fields + .all_addresses() + .iter() + .enumerate() + .map(|(i, a)| { + let params = if i == 0 { ";TYPE=PREF" } else { "" }; + format!("ADR{params}:;;{};;;;", escape_text(a)) + }) + .collect() +} + +fn single(name: &str, value: &str, escape: bool) -> Vec { + if value.trim().is_empty() { + Vec::new() + } else if escape { + vec![format!("{name}:{}", escape_text(value))] + } else { + vec![format!("{name}:{}", value.trim())] + } +} + +/// Serialize a webmail-created contact (no stored CardDAV object) as vCard 3.0. +pub fn contact_to_vcard(contact: &Contact) -> String { + let fields = CardFields::from_contact(contact); + let mut lines = vec![ + "BEGIN:VCARD".to_string(), + "VERSION:3.0".into(), + format!("PRODID:{PRODID}"), + format!("UID:{}", escape_text(&contact.id)), + format!("REV:{}", super::ical::stamp(&contact.updated_at)), + ]; + lines.extend(name_lines(&fields)); + lines.extend(email_lines(&fields)); + lines.extend(phone_lines(&fields)); + lines.extend(address_lines(&fields)); + lines.extend(single("ORG", &fields.company, true)); + lines.extend(single("TITLE", &fields.job_title, true)); + lines.extend(single("NOTE", &fields.notes, true)); + lines.extend(single("URL", &fields.website, false)); + lines.extend(single("BDAY", &fields.birthday, false)); + lines.push("END:VCARD".into()); + content::join(&lines) +} + +/// Re-apply webmail edits to a stored vCard. `None` when nothing changed. +pub fn patch_card(raw: &str, contact: &Contact) -> Option { + let wanted = CardFields::from_contact(contact); + let current = match parse_card(raw) { + Ok(parsed) => parsed.fields, + Err(_) => return Some(contact_to_vcard(contact)), + }; + if current == wanted { + return None; + } + let lower = |v: Vec| { + v.into_iter() + .map(|s| s.to_ascii_lowercase()) + .collect::>() + }; + + let mut drop: Vec<&str> = vec!["REV"]; + let mut add: Vec = Vec::new(); + if current.name != wanted.name { + drop.extend(["FN", "N"]); + add.extend(name_lines(&wanted)); + } + if lower(current.all_emails()) != lower(wanted.all_emails()) { + drop.push("EMAIL"); + add.extend(email_lines(&wanted)); + } + if current.all_phones() != wanted.all_phones() { + drop.push("TEL"); + add.extend(phone_lines(&wanted)); + } + if current.all_addresses() != wanted.all_addresses() { + drop.push("ADR"); + add.extend(address_lines(&wanted)); + } + for (name, old, new, escape) in [ + ("ORG", ¤t.company, &wanted.company, true), + ("TITLE", ¤t.job_title, &wanted.job_title, true), + ("NOTE", ¤t.notes, &wanted.notes, true), + ("URL", ¤t.website, &wanted.website, false), + ("BDAY", ¤t.birthday, &wanted.birthday, false), + ] { + if old != new { + drop.push(name); + add.extend(single(name, new, escape)); + } + } + add.push(format!("REV:{}", Utc::now().format("%Y%m%dT%H%M%SZ"))); + + let mut out = Vec::new(); + let mut inside = false; + for line in content::unfold(raw) { + if let Some((begin, name)) = content::component_marker(&line) + && name == "VCARD" + { + if !begin && inside { + out.append(&mut add); + } + inside = begin; + out.push(line); + continue; + } + if inside + && let Some(parsed) = content::parse_line(&line) + && drop.contains(&parsed.name.as_str()) + { + continue; + } + out.push(line); + } + Some(content::join(&out)) +} + +/// Evaluate an RFC 6352 `` against a vCard. +pub fn matches_filter(card: &str, filter: Option<&Element>) -> bool { + let Some(filter) = filter else { + return true; + }; + let prop_filters: Vec<&Element> = filter.children_named(NS_CARDDAV, "prop-filter").collect(); + if prop_filters.is_empty() { + return true; + } + let Ok(lines) = card_lines(card) else { + return false; + }; + let all_of = filter + .attr("test") + .is_some_and(|t| t.eq_ignore_ascii_case("allof")); + let mut results = prop_filters + .iter() + .map(|pf| prop_filter_matches(&lines, pf)); + if all_of { + results.all(|r| r) + } else { + results.any(|r| r) + } +} + +fn prop_filter_matches(lines: &[Line], filter: &Element) -> bool { + let name = filter.attr("name").unwrap_or("").to_ascii_uppercase(); + let values: Vec = lines + .iter() + .filter(|l| l.name == name) + .map(|l| l.text()) + .collect(); + if filter.child(NS_CARDDAV, "is-not-defined").is_some() { + return values.is_empty(); + } + let matches: Vec<&Element> = filter.children_named(NS_CARDDAV, "text-match").collect(); + if matches.is_empty() { + return !values.is_empty(); + } + let all_of = filter + .attr("test") + .is_some_and(|t| t.eq_ignore_ascii_case("allof")); + let mut results = matches.iter().map(|tm| text_match(&values, tm)); + if all_of { + results.all(|r| r) + } else { + results.any(|r| r) + } +} + +fn text_match(values: &[String], element: &Element) -> bool { + let needle = element.text.trim(); + let octet = element + .attr("collation") + .is_some_and(|c| c.eq_ignore_ascii_case("i;octet")); + let negate = element + .attr("negate-condition") + .is_some_and(|n| n.eq_ignore_ascii_case("yes")); + let match_type = element.attr("match-type").unwrap_or("contains"); + let fold = |s: &str| { + if octet { + s.to_string() + } else { + s.to_lowercase() + } + }; + let needle = fold(needle); + let hit = values.iter().any(|value| { + let value = fold(value); + match match_type { + "equals" => value == needle, + "starts-with" => value.starts_with(&needle), + "ends-with" => value.ends_with(&needle), + _ => value.contains(&needle), + } + }); + hit != negate +} + +#[cfg(test)] +mod tests { + use super::*; + + fn contact() -> Contact { + Contact { + id: "c1".into(), + email: "jan@example.com".into(), + name: "Jan Kowalski".into(), + company: "ACME".into(), + notes: String::new(), + phone: "+48 600 000 000".into(), + job_title: String::new(), + address: String::new(), + website: String::new(), + birthday: String::new(), + emails: vec!["jan@private.example".into()], + phones: vec![], + addresses: vec![], + is_favorite: false, + last_contacted: None, + contact_count: 0, + source: "manual".into(), + created_at: "2026-11-01 10:00:00".into(), + updated_at: "2026-11-01 10:00:00".into(), + has_photo: false, + } + } + + const PHONE_CARD: &str = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:phone-1\r\nFN:Anna Nowak\r\nN:Nowak;Anna;;;\r\n\ +item1.EMAIL;TYPE=INTERNET:anna@work.example\r\nitem1.X-ABLabel:work\r\nEMAIL;TYPE=INTERNET,PREF:anna@example.com\r\n\ +TEL;TYPE=CELL:+48 111\r\nTEL;TYPE=WORK:+48 222\r\nORG:Firma;Dział\r\nADR;TYPE=HOME:;;Polna 1;Warszawa;;00-001;PL\r\n\ +X-SOCIALPROFILE:keep\r\nPHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\r\nEND:VCARD\r\n"; + + #[test] + fn parses_phone_card() { + let card = parse_card(PHONE_CARD).unwrap(); + let f = card.fields; + assert_eq!(f.name, "Anna Nowak"); + assert_eq!(f.email, "anna@example.com"); + assert_eq!(f.emails, vec!["anna@work.example"]); + assert_eq!(f.phone, "+48 111"); + assert_eq!(f.phones, vec!["+48 222"]); + assert_eq!(f.company, "Firma, Dział"); + assert_eq!(f.address, "Polna 1, Warszawa, 00-001, PL"); + let (ct, bytes) = card.photo.unwrap(); + assert_eq!(ct, "image/jpeg"); + assert_eq!(&bytes[..3], &[0xFF, 0xD8, 0xFF]); + } + + #[test] + fn card_without_email_and_multiple_cards() { + let card = parse_card("BEGIN:VCARD\nVERSION:4.0\nFN:Plumber\nTEL:tel:+48 999\nEND:VCARD\n") + .unwrap(); + assert_eq!(card.fields.email, ""); + assert_eq!(card.fields.phone, "+48 999"); + let two = "BEGIN:VCARD\nFN:a\nEND:VCARD\nBEGIN:VCARD\nFN:b\nEND:VCARD\n"; + assert!(parse_card(two).is_err()); + } + + #[test] + fn generated_card_roundtrips() { + let c = contact(); + let vcf = contact_to_vcard(&c); + assert!(vcf.contains("UID:c1\r\n")); + assert!(vcf.contains("N:Kowalski;Jan;;;\r\n")); + assert_eq!( + parse_card(&vcf).unwrap().fields, + CardFields::from_contact(&c) + ); + } + + #[test] + fn webmail_edit_patches_only_changed_properties() { + let parsed = parse_card(PHONE_CARD).unwrap().fields; + let mut c = contact(); + c.name = parsed.name.clone(); + c.email = parsed.email.clone(); + c.emails = parsed.emails.clone(); + c.phone = parsed.phone.clone(); + c.phones = parsed.phones.clone(); + c.company = parsed.company.clone(); + c.address = parsed.address.clone(); + assert!(patch_card(PHONE_CARD, &c).is_none()); + + c.job_title = "CTO".into(); + let patched = patch_card(PHONE_CARD, &c).unwrap(); + assert!(patched.contains("TITLE:CTO\r\n")); + assert!(patched.contains("item1.EMAIL;TYPE=INTERNET:anna@work.example\r\n")); + assert!(patched.contains("X-SOCIALPROFILE:keep\r\n")); + assert!(patched.contains("ADR;TYPE=HOME:;;Polna 1;Warszawa;;00-001;PL\r\n")); + assert!(patched.contains("PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\r\n")); + assert_eq!(parse_card(&patched).unwrap().fields.job_title, "CTO"); + assert!(patched.trim_end().ends_with("END:VCARD")); + } + + #[test] + fn addressbook_query_filter() { + let filter = super::super::xml::parse( + br#" + nowak + zzz +"#, + ) + .unwrap() + .unwrap(); + assert!(matches_filter(PHONE_CARD, Some(&filter))); + let vcf = contact_to_vcard(&contact()); + assert!(!matches_filter(&vcf, Some(&filter))); + } +} diff --git a/src/dav/xml.rs b/src/dav/xml.rs new file mode 100644 index 0000000..8440f09 --- /dev/null +++ b/src/dav/xml.rs @@ -0,0 +1,374 @@ +//! Minimal namespace-aware XML handling for WebDAV request bodies and +//! `207 Multi-Status` responses. + +use quick_xml::NsReader; +use quick_xml::events::Event; +use quick_xml::name::ResolveResult; + +pub const NS_DAV: &str = "DAV:"; +pub const NS_CALDAV: &str = "urn:ietf:params:xml:ns:caldav"; +pub const NS_CARDDAV: &str = "urn:ietf:params:xml:ns:carddav"; +pub const NS_CS: &str = "http://calendarserver.org/ns/"; +pub const NS_APPLE: &str = "http://apple.com/ns/ical/"; + +/// Namespace declarations placed on every multistatus root element. +pub const ROOT_XMLNS: &str = r#"xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cr="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/" xmlns:ic="http://apple.com/ns/ical/""#; + +/// A fully qualified XML name. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct QName { + pub ns: String, + pub name: String, +} + +impl QName { + pub fn new(ns: &str, name: &str) -> Self { + Self { + ns: ns.to_string(), + name: name.to_string(), + } + } + + pub fn is(&self, ns: &str, name: &str) -> bool { + self.ns == ns && self.name == name + } + + /// Prefix used for this namespace in responses, if it is one of ours. + fn known_prefix(&self) -> Option<&'static str> { + match self.ns.as_str() { + NS_DAV => Some("d"), + NS_CALDAV => Some("c"), + NS_CARDDAV => Some("cr"), + NS_CS => Some("cs"), + NS_APPLE => Some("ic"), + _ => None, + } + } + + /// An empty element for this name, e.g. for `404` propstat blocks. + pub fn empty_element(&self) -> String { + match self.known_prefix() { + Some(prefix) => format!("<{prefix}:{}/>", self.name), + None if self.ns.is_empty() => format!("<{} xmlns=\"\"/>", self.name), + None => format!("", self.name, escape(&self.ns)), + } + } +} + +/// A parsed XML element. +#[derive(Debug, Clone, Default)] +pub struct Element { + pub ns: String, + pub name: String, + pub attrs: Vec<(String, String)>, + pub children: Vec, + pub text: String, +} + +impl Element { + pub fn qname(&self) -> QName { + QName::new(&self.ns, &self.name) + } + + pub fn is(&self, ns: &str, name: &str) -> bool { + self.ns == ns && self.name == name + } + + pub fn child(&self, ns: &str, name: &str) -> Option<&Element> { + self.children.iter().find(|c| c.is(ns, name)) + } + + pub fn children_named<'a>( + &'a self, + ns: &'a str, + name: &'a str, + ) -> impl Iterator + 'a { + self.children.iter().filter(move |c| c.is(ns, name)) + } + + pub fn attr(&self, name: &str) -> Option<&str> { + self.attrs + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.as_str()) + } +} + +/// Parse an XML document into an element tree. Returns `Ok(None)` for an empty body. +pub fn parse(body: &[u8]) -> Result, String> { + let text = std::str::from_utf8(body).map_err(|_| "request body is not UTF-8".to_string())?; + if text.trim().is_empty() { + return Ok(None); + } + let mut reader = NsReader::from_str(text); + let mut stack: Vec = Vec::new(); + let mut root: Option = None; + + loop { + let (resolved, event) = reader + .read_resolved_event() + .map_err(|e| format!("malformed XML: {e}"))?; + let ns = match resolved { + ResolveResult::Bound(ns) => String::from_utf8_lossy(ns.as_ref()).into_owned(), + _ => String::new(), + }; + match event { + Event::Start(start) | Event::Empty(start) if stack.len() > 64 => { + let _ = start; + return Err("XML nesting too deep".into()); + } + Event::Start(start) => { + stack.push(element_from(&ns, &start)?); + } + Event::Empty(start) => { + let element = element_from(&ns, &start)?; + attach(&mut stack, &mut root, element); + } + Event::End(_) => { + let element = stack.pop().ok_or("unbalanced XML")?; + attach(&mut stack, &mut root, element); + } + Event::Text(text) => { + if let Some(top) = stack.last_mut() { + let value = text + .xml10_content() + .map_err(|e| format!("bad XML text: {e}"))?; + top.text.push_str(&value); + } + } + Event::CData(data) => { + if let Some(top) = stack.last_mut() { + let value = data.decode().map_err(|e| format!("bad CDATA: {e}"))?; + top.text.push_str(&value); + } + } + Event::GeneralRef(reference) => { + if let Some(top) = stack.last_mut() { + if let Some(ch) = reference + .resolve_char_ref() + .map_err(|e| format!("bad character reference: {e}"))? + { + top.text.push(ch); + } else { + let name = reference.decode().map_err(|e| format!("bad entity: {e}"))?; + let resolved = match name.as_ref() { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + other => return Err(format!("unknown XML entity &{other};")), + }; + top.text.push(resolved); + } + } + } + Event::Eof => break, + _ => {} + } + } + if !stack.is_empty() { + return Err("unexpected end of XML".into()); + } + root.map(Some) + .ok_or_else(|| "XML document has no root".to_string()) +} + +fn element_from(ns: &str, start: &quick_xml::events::BytesStart<'_>) -> Result { + let name = String::from_utf8_lossy(start.local_name().as_ref()).into_owned(); + let mut attrs = Vec::new(); + for attr in start.attributes().with_checks(false) { + let attr = attr.map_err(|e| format!("bad XML attribute: {e}"))?; + let key = String::from_utf8_lossy(attr.key.local_name().as_ref()).into_owned(); + if attr.key.as_ref().starts_with(b"xmlns") { + continue; + } + let value = attr + .normalized_value(quick_xml::XmlVersion::Implicit1_0) + .map_err(|e| format!("bad XML attribute value: {e}"))? + .into_owned(); + attrs.push((key, value)); + } + Ok(Element { + ns: ns.to_string(), + name, + attrs, + children: Vec::new(), + text: String::new(), + }) +} + +fn attach(stack: &mut [Element], root: &mut Option, element: Element) { + match stack.last_mut() { + Some(parent) => parent.children.push(element), + None => { + if root.is_none() { + *root = Some(element); + } + } + } +} + +/// Escape text for use in XML content or attribute values. +pub fn escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + // Keep CR of CRLF line endings in calendar/address data: XML parsers + // normalize a literal CR away, a character reference survives. + '\r' => out.push_str(" "), + // Characters that are not allowed in XML 1.0 documents. + c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {} + c => out.push(c), + } + } + out +} + +/// Which properties a PROPFIND / REPORT asks for. +#[derive(Debug, Clone)] +pub enum PropRequest { + AllProp, + PropName, + Props(Vec), +} + +/// Extract the requested properties from a `` / `` / `` parent. +pub fn prop_request(parent: Option<&Element>) -> PropRequest { + let Some(parent) = parent else { + return PropRequest::AllProp; + }; + if parent.child(NS_DAV, "propname").is_some() { + return PropRequest::PropName; + } + if let Some(prop) = parent.child(NS_DAV, "prop") { + return PropRequest::Props(prop.children.iter().map(Element::qname).collect()); + } + PropRequest::AllProp +} + +/// Incrementally built `207 Multi-Status` body. +pub struct MultiStatus { + body: String, +} + +impl Default for MultiStatus { + fn default() -> Self { + Self::new() + } +} + +impl MultiStatus { + pub fn new() -> Self { + let mut body = String::from(r#""#); + body.push_str(&format!("")); + Self { body } + } + + /// Add a response with found (`200`) and missing (`404`) properties. + pub fn propstat(&mut self, href: &str, found: &[String], missing: &[QName]) { + self.body.push_str(""); + self.body.push_str(&escape(href)); + self.body.push_str(""); + if !found.is_empty() || missing.is_empty() { + self.body.push_str(""); + for prop in found { + self.body.push_str(prop); + } + self.body + .push_str("HTTP/1.1 200 OK"); + } + if !missing.is_empty() { + self.body.push_str(""); + for name in missing { + self.body.push_str(&name.empty_element()); + } + self.body + .push_str("HTTP/1.1 404 Not Found"); + } + self.body.push_str(""); + } + + /// Add a response whose properties all have the same status (e.g. PROPPATCH refusals). + pub fn propstat_status(&mut self, href: &str, names: &[QName], status: &str) { + self.body.push_str(""); + self.body.push_str(&escape(href)); + self.body.push_str(""); + for name in names { + self.body.push_str(&name.empty_element()); + } + self.body.push_str("HTTP/1.1 "); + self.body.push_str(status); + self.body.push_str(""); + } + + /// Add a response that only carries a status (e.g. `404` in multiget). + pub fn status(&mut self, href: &str, status: &str) { + self.body.push_str(""); + self.body.push_str(&escape(href)); + self.body.push_str("HTTP/1.1 "); + self.body.push_str(status); + self.body.push_str(""); + } + + pub fn finish(mut self) -> String { + self.body.push_str(""); + self.body + } +} + +/// `` body for failed preconditions. +pub fn error_body(inner: &str) -> String { + format!(r#"{inner}"#) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_propfind_with_namespaces_and_entities() { + let body = br#" + + +"#; + let root = parse(body).unwrap().unwrap(); + assert!(root.is(NS_DAV, "propfind")); + let PropRequest::Props(props) = prop_request(Some(&root)) else { + panic!("expected explicit props"); + }; + assert_eq!(props.len(), 4); + assert!(props[2].is(NS_CS, "getctag")); + assert!(props[3].is(NS_CALDAV, "supported-calendar-component-set")); + + let href = parse(br#"/x/a&b%20c.ics"#) + .unwrap() + .unwrap(); + assert_eq!(href.text, "/x/a&b%20c.ics"); + // CRLF survives a round trip through escape() and an XML parser. + let data = format!("{}", escape("A\r\nB")); + assert_eq!(parse(data.as_bytes()).unwrap().unwrap().text, "A\r\nB"); + } + + #[test] + fn empty_body_means_allprop() { + assert!(parse(b" ").unwrap().is_none()); + assert!(matches!(prop_request(None), PropRequest::AllProp)); + } + + #[test] + fn rejects_malformed_xml() { + assert!(parse(b"").is_err()); + } + + #[test] + fn unknown_namespace_is_declared_inline() { + let name = QName::new("urn:example", "foo"); + assert_eq!(name.empty_element(), r#""#); + } +} diff --git a/src/db/calendar.rs b/src/db/calendar.rs index 184928e..90a79be 100644 --- a/src/db/calendar.rs +++ b/src/db/calendar.rs @@ -148,7 +148,9 @@ pub struct MeetingTemplate { pub created_at: String, } -fn row_to_event(row: &crate::db::pool::Row<'_>) -> Result { +pub(crate) fn row_to_event( + row: &crate::db::pool::Row<'_>, +) -> Result { let all_day_int: i32 = row.get(6)?; Ok(CalendarEvent { id: row.get(0)?, @@ -374,6 +376,9 @@ pub fn update_event( return Ok(None); } + // Keep the stored CalDAV object (if this event came from a DAV client) in sync. + crate::db::dav::refresh_event_object(conn, id); + get_event(conn, id) } diff --git a/src/db/contacts.rs b/src/db/contacts.rs index 9f0cd70..561c4f0 100644 --- a/src/db/contacts.rs +++ b/src/db/contacts.rs @@ -33,7 +33,9 @@ pub struct Contact { pub has_photo: bool, } -fn row_to_contact(row: &crate::db::pool::Row<'_>) -> Result { +pub(crate) fn row_to_contact( + row: &crate::db::pool::Row<'_>, +) -> Result { Ok(Contact { id: row.get(0)?, email: row.get(1)?, @@ -65,7 +67,16 @@ fn escape_like(term: &str) -> String { .replace('_', "!_") } +/// Insert or update a contact. When the contact came from a CardDAV client the +/// stored vCard is patched so DAV clients see the webmail edit. pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> { + upsert_contact_row(conn, contact)?; + crate::db::dav::refresh_contact_object(conn, &contact.id); + Ok(()) +} + +/// Insert or update the contact columns only (no CardDAV object refresh). +pub(crate) fn upsert_contact_row(conn: &Connection, contact: &Contact) -> Result<(), String> { conn.execute( crate::dbq!(db_contacts::Q_001), params![ @@ -280,6 +291,11 @@ pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), Str #[allow(dead_code)] pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> { + // Since V055 the e-mail index is no longer unique (CardDAV address books may + // hold several cards sharing one address), so the existence check is explicit. + if get_contact_by_email(conn, email)?.is_some() { + return Ok(()); + } let id = Uuid::new_v4().to_string(); conn.execute(crate::dbq!(db_contacts::Q_007), params![id, email, name]) .map_err(|e| format!("Failed to auto-add contact: {e}"))?; diff --git a/src/db/dav.rs b/src/db/dav.rs new file mode 100644 index 0000000..4494349 --- /dev/null +++ b/src/db/dav.rs @@ -0,0 +1,401 @@ +//! Storage for the CalDAV / CardDAV server (`src/dav`). +//! +//! Every calendar event and contact is exposed as a DAV resource. Rows created +//! over DAV keep the uploaded object in `dav_data` and their resource name in +//! `dav_name`; rows created in the webmail have neither and are served under +//! `.ics` / `.vcf` with a generated body. + +use crate::db::calendar::{CalendarEvent, CreateEvent}; +use crate::db::contacts::Contact; +use crate::db::pool::{Connection, DbError, params}; +use crate::queries::sqlite::queries::db_calendar::EVENT_SELECT_COLS; +use crate::queries::sqlite::queries::db_contacts::CONTACT_SELECT_COLS; + +/// An event together with its DAV representation. +#[derive(Debug, Clone)] +pub struct DavEvent { + pub event: CalendarEvent, + pub dav_name: Option, + pub dav_data: Option, +} + +impl DavEvent { + pub fn resource_name(&self) -> String { + self.dav_name + .clone() + .unwrap_or_else(|| format!("{}.ics", self.event.id)) + } + + pub fn body(&self) -> String { + self.dav_data + .clone() + .unwrap_or_else(|| crate::dav::ical::event_to_ical(&self.event)) + } +} + +/// A contact together with its DAV representation. +#[derive(Debug, Clone)] +pub struct DavContact { + pub contact: Contact, + pub dav_name: Option, + pub dav_data: Option, +} + +impl DavContact { + pub fn resource_name(&self) -> String { + self.dav_name + .clone() + .unwrap_or_else(|| format!("{}.vcf", self.contact.id)) + } + + pub fn body(&self) -> String { + self.dav_data + .clone() + .unwrap_or_else(|| crate::dav::vcard::contact_to_vcard(&self.contact)) + } +} + +fn row_to_dav_event(row: &crate::db::pool::Row<'_>) -> Result { + Ok(DavEvent { + event: crate::db::calendar::row_to_event(row)?, + dav_name: row.get(21)?, + dav_data: row.get(22)?, + }) +} + +fn row_to_dav_contact(row: &crate::db::pool::Row<'_>) -> Result { + Ok(DavContact { + contact: crate::db::contacts::row_to_contact(row)?, + dav_name: row.get(20)?, + dav_data: row.get(21)?, + }) +} + +fn event_select(filter: &str) -> String { + format!("SELECT {EVENT_SELECT_COLS}, dav_name, dav_data FROM calendar_events {filter}") +} + +fn contact_select(filter: &str) -> String { + format!("SELECT {CONTACT_SELECT_COLS}, dav_name, dav_data FROM contacts {filter}") +} + +fn optional(result: Result, what: &str) -> Result, String> { + match result { + Ok(value) => Ok(Some(value)), + Err(DbError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("Failed to load {what}: {e}")), + } +} + +/// The id a generated resource name refers to (`.ics` -> ``). +fn generated_id<'a>(name: &'a str, extension: &str) -> &'a str { + name.strip_suffix(extension).unwrap_or(name) +} + +// ----------------------------------------------------------------- events + +pub fn list_events(conn: &Connection) -> Result, String> { + let sql = event_select("ORDER BY start_time ASC, id ASC"); + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare DAV event list: {e}"))?; + let rows = stmt + .query_map([], row_to_dav_event) + .map_err(|e| format!("Failed to list DAV events: {e}"))?; + rows.map(|r| r.map_err(|e| format!("Failed to read DAV event: {e}"))) + .collect() +} + +pub fn find_event_by_name(conn: &Connection, name: &str) -> Result, String> { + let sql = event_select("WHERE dav_name = ?1 OR (dav_name IS NULL AND id = ?2)"); + optional( + conn.query_row( + &sql, + params![name, generated_id(name, ".ics")], + row_to_dav_event, + ), + "DAV event", + ) +} + +pub fn find_event_by_uid(conn: &Connection, uid: &str) -> Result, String> { + let sql = event_select("WHERE source_uid = ?1 OR (source_uid IS NULL AND id = ?1)"); + optional( + conn.query_row(&sql, params![uid], row_to_dav_event), + "DAV event by UID", + ) +} + +pub fn get_event(conn: &Connection, id: &str) -> Result, String> { + let sql = event_select("WHERE id = ?1"); + optional( + conn.query_row(&sql, params![id], row_to_dav_event), + "DAV event", + ) +} + +/// Insert or replace an event received over CalDAV. Returns the row id. +pub fn save_event( + conn: &Connection, + existing_id: Option<&str>, + data: &CreateEvent, + dav_name: &str, + dav_data: &str, +) -> Result { + let id = match existing_id { + Some(id) => { + conn.execute( + "UPDATE calendar_events SET title = ?1, description = ?2, location = ?3, \ + start_time = ?4, end_time = ?5, all_day = ?6, recurrence_rule = ?7, attendees = ?8, \ + organizer = ?9, status = ?10, source_uid = ?11, meeting_url = ?12, color = ?13, \ + reminder_minutes = ?14, reminder_extra_minutes = ?15, updated_at = datetime('now') \ + WHERE id = ?16", + params![ + data.title, + data.description, + data.location, + data.start_time, + data.end_time, + data.all_day as i32, + data.recurrence_rule, + data.attendees, + data.organizer, + data.status, + data.source_uid, + data.meeting_url, + data.color, + data.reminder_minutes, + data.reminder_extra_minutes, + id, + ], + ) + .map_err(|e| format!("Failed to update DAV event: {e}"))?; + id.to_string() + } + None => crate::db::calendar::create_event(conn, data)?.id, + }; + set_event_dav(conn, &id, Some(dav_name), dav_data)?; + Ok(id) +} + +fn set_event_dav( + conn: &Connection, + id: &str, + dav_name: Option<&str>, + dav_data: &str, +) -> Result<(), String> { + match dav_name { + Some(name) => conn.execute( + "UPDATE calendar_events SET dav_name = ?1, dav_data = ?2 WHERE id = ?3", + params![name, dav_data, id], + ), + None => conn.execute( + "UPDATE calendar_events SET dav_data = ?1 WHERE id = ?2", + params![dav_data, id], + ), + } + .map_err(|e| format!("Failed to store CalDAV object: {e}"))?; + Ok(()) +} + +/// After a webmail edit, rewrite the stored CalDAV object of `id` (if any) so +/// DAV clients see the change. Best effort: failures are logged, not returned. +pub fn refresh_event_object(conn: &Connection, id: &str) { + let result = (|| -> Result<(), String> { + let Some(row) = get_event(conn, id)? else { + return Ok(()); + }; + let Some(raw) = row.dav_data.as_deref() else { + return Ok(()); + }; + if let Some(patched) = crate::dav::ical::patch_object(raw, &row.event) { + set_event_dav(conn, id, None, &patched)?; + } + Ok(()) + })(); + if let Err(error) = result { + tracing::warn!(%error, "failed to refresh CalDAV object after webmail edit"); + } +} + +// --------------------------------------------------------------- contacts + +pub fn list_contacts(conn: &Connection) -> Result, String> { + let sql = contact_select("ORDER BY id ASC"); + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare DAV contact list: {e}"))?; + let rows = stmt + .query_map([], row_to_dav_contact) + .map_err(|e| format!("Failed to list DAV contacts: {e}"))?; + rows.map(|r| r.map_err(|e| format!("Failed to read DAV contact: {e}"))) + .collect() +} + +pub fn find_contact_by_name(conn: &Connection, name: &str) -> Result, String> { + let sql = contact_select("WHERE dav_name = ?1 OR (dav_name IS NULL AND id = ?2)"); + optional( + conn.query_row( + &sql, + params![name, generated_id(name, ".vcf")], + row_to_dav_contact, + ), + "DAV contact", + ) +} + +pub fn get_contact(conn: &Connection, id: &str) -> Result, String> { + let sql = contact_select("WHERE id = ?1"); + optional( + conn.query_row(&sql, params![id], row_to_dav_contact), + "DAV contact", + ) +} + +/// Insert or replace a contact received over CardDAV. +pub fn save_contact( + conn: &Connection, + contact: &Contact, + dav_name: &str, + dav_data: &str, +) -> Result<(), String> { + crate::db::contacts::upsert_contact_row(conn, contact)?; + set_contact_dav(conn, &contact.id, Some(dav_name), dav_data) +} + +fn set_contact_dav( + conn: &Connection, + id: &str, + dav_name: Option<&str>, + dav_data: &str, +) -> Result<(), String> { + match dav_name { + Some(name) => conn.execute( + "UPDATE contacts SET dav_name = ?1, dav_data = ?2 WHERE id = ?3", + params![name, dav_data, id], + ), + None => conn.execute( + "UPDATE contacts SET dav_data = ?1 WHERE id = ?2", + params![dav_data, id], + ), + } + .map_err(|e| format!("Failed to store CardDAV object: {e}"))?; + Ok(()) +} + +/// Counterpart of [`refresh_event_object`] for contacts. +pub fn refresh_contact_object(conn: &Connection, id: &str) { + let result = (|| -> Result<(), String> { + let Some(row) = get_contact(conn, id)? else { + return Ok(()); + }; + let Some(raw) = row.dav_data.as_deref() else { + return Ok(()); + }; + if let Some(patched) = crate::dav::vcard::patch_card(raw, &row.contact) { + set_contact_dav(conn, id, None, &patched)?; + } + Ok(()) + })(); + if let Err(error) = result { + tracing::warn!(%error, "failed to refresh CardDAV object after webmail edit"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::pool::open_test_db; + + fn sample() -> CreateEvent { + serde_json::from_value(serde_json::json!({ + "title": "Webmail event", + "start_time": "2026-11-10T09:00:00.000Z", + "end_time": "2026-11-10T10:00:00.000Z" + })) + .unwrap() + } + + #[test] + fn webmail_events_get_generated_names() { + let conn = open_test_db(); + let event = crate::db::calendar::create_event(&conn, &sample()).unwrap(); + let found = find_event_by_name(&conn, &format!("{}.ics", event.id)) + .unwrap() + .unwrap(); + assert_eq!(found.event.id, event.id); + assert!(found.dav_data.is_none()); + assert!(found.body().contains("SUMMARY:Webmail event")); + assert!(find_event_by_uid(&conn, &event.id).unwrap().is_some()); + } + + #[test] + fn dav_events_store_object_and_follow_webmail_edits() { + let conn = open_test_db(); + let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u-1\r\nDTSTART:20261110T090000Z\r\nDTEND:20261110T100000Z\r\nSUMMARY:From phone\r\nX-KEEP:1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + let parsed = crate::dav::ical::parse_object(raw).unwrap(); + let data = parsed.fields.to_create_event(&parsed.uid, "caldav"); + let id = save_event(&conn, None, &data, "phone-1.ics", raw).unwrap(); + let stored = find_event_by_name(&conn, "phone-1.ics").unwrap().unwrap(); + assert_eq!(stored.event.id, id); + assert_eq!(stored.body(), raw); + assert_eq!( + find_event_by_uid(&conn, "u-1").unwrap().unwrap().event.id, + id + ); + + let update: crate::db::calendar::UpdateEvent = + serde_json::from_value(serde_json::json!({"title": "Edited in webmail"})).unwrap(); + crate::db::calendar::update_event(&conn, &id, &update).unwrap(); + let body = find_event_by_name(&conn, "phone-1.ics") + .unwrap() + .unwrap() + .body(); + assert!(body.contains("SUMMARY:Edited in webmail\r\n")); + assert!(body.contains("X-KEEP:1\r\n")); + } + + #[test] + fn dav_contacts_allow_shared_addresses_and_follow_webmail_edits() { + let conn = open_test_db(); + let raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:card-1\r\nFN:Anna\r\nEMAIL:anna@example.com\r\nX-KEEP:1\r\nEND:VCARD\r\n"; + let parsed = crate::dav::vcard::parse_card(raw).unwrap(); + let mut contact = crate::db::contacts::Contact { + id: "id-1".into(), + email: parsed.fields.email.clone(), + name: parsed.fields.name.clone(), + company: String::new(), + notes: String::new(), + phone: String::new(), + job_title: String::new(), + address: String::new(), + website: String::new(), + birthday: String::new(), + emails: vec![], + phones: vec![], + addresses: vec![], + is_favorite: false, + last_contacted: None, + contact_count: 0, + source: "carddav".into(), + created_at: "2026-11-01 00:00:00".into(), + updated_at: "2026-11-01 00:00:00".into(), + has_photo: false, + }; + save_contact(&conn, &contact, "card-1.vcf", raw).unwrap(); + // A second card with the same address must not collide (V055 index). + contact.id = "id-2".into(); + save_contact(&conn, &contact, "card-2.vcf", raw).unwrap(); + assert_eq!(list_contacts(&conn).unwrap().len(), 2); + + contact.id = "id-1".into(); + contact.company = "ACME".into(); + crate::db::contacts::upsert_contact(&conn, &contact).unwrap(); + let body = find_contact_by_name(&conn, "card-1.vcf") + .unwrap() + .unwrap() + .body(); + assert!(body.contains("ORG:ACME\r\n")); + assert!(body.contains("X-KEEP:1\r\n")); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index c029d19..601e817 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -2,6 +2,7 @@ pub mod blob_delete_outbox; pub mod calendar; pub mod contact_groups; pub mod contacts; +pub mod dav; pub mod display_preferences; pub mod drafts; pub mod folder_preferences; diff --git a/src/db/pool.rs b/src/db/pool.rs index 5d00d70..4ceeea5 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -32,6 +32,10 @@ const SQLITE_MIGRATIONS: &[(u32, &str)] = &[ "../../migrations/sqlite/V054__theme_schedule_account_color_and_reminders.sql" ), ), + ( + 55, + include_str!("../../migrations/sqlite/V055__caldav_carddav.sql"), + ), ]; const MYSQL_MIGRATIONS: &[(u32, &str)] = &[ ( @@ -42,6 +46,10 @@ const MYSQL_MIGRATIONS: &[(u32, &str)] = &[ 54, include_str!("../../migrations/mysql/V054__theme_schedule_account_color_and_reminders.sql"), ), + ( + 55, + include_str!("../../migrations/mysql/V055__caldav_carddav.sql"), + ), ]; const PGSQL_MIGRATIONS: &[(u32, &str)] = &[ ( @@ -52,6 +60,10 @@ const PGSQL_MIGRATIONS: &[(u32, &str)] = &[ 54, include_str!("../../migrations/pgsql/V054__theme_schedule_account_color_and_reminders.sql"), ), + ( + 55, + include_str!("../../migrations/pgsql/V055__caldav_carddav.sql"), + ), ]; const TENANT_TABLES: &[&str] = &[ @@ -2654,6 +2666,8 @@ mod tests { CREATE TABLE u_test__display_preferences (id INTEGER PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system', interface_skin TEXT NOT NULL DEFAULT 'rustbox');\n\ CREATE TABLE u_test__calendar_events (id TEXT PRIMARY KEY, reminder_minutes INTEGER);\n\ CREATE TABLE u_test__calendar_settings (id INTEGER PRIMARY KEY);\n\ + CREATE TABLE u_test__contacts (id TEXT PRIMARY KEY, email TEXT NOT NULL);\n\ + CREATE UNIQUE INDEX u_test__idx_contacts_email ON u_test__contacts(email);\n\ INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES('test', 'u_test__', 52);\n\ INSERT INTO u_test__display_preferences(id, interface_skin) VALUES(1, 'nord');\n\ INSERT INTO u_test__calendar_events(id, reminder_minutes) VALUES('e1', 15);\n\ @@ -2701,6 +2715,21 @@ mod tests { .unwrap(); assert_eq!(reminder, 15); assert_eq!(extra, None); + let (dav_name, dav_data): (Option, Option) = conn + .query_row( + "SELECT dav_name, dav_data FROM u_test__calendar_events WHERE id='e1'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!((dav_name, dav_data), (None, None)); + // V055: CardDAV address books may hold several cards sharing one address. + conn.execute_batch( + "INSERT INTO u_test__contacts(id, email) VALUES('c1', 'a@example.com');\n\ + INSERT INTO u_test__contacts(id, email) VALUES('c2', 'a@example.com');\n\ + UPDATE u_test__contacts SET dav_name='c2.vcf', dav_data='BEGIN:VCARD' WHERE id='c2';", + ) + .unwrap(); let (default_reminder, default_extra): (Option, Option) = conn .query_row( "SELECT default_reminder_minutes, default_extra_reminder_minutes FROM u_test__calendar_settings WHERE id=1", diff --git a/src/main.rs b/src/main.rs index 47281b5..bf99df0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod auth; mod calendar; mod config; +mod dav; mod db; mod email_theme; mod error; diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 009b531..3de5e7c 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -154,7 +154,7 @@ fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, Stri /// Client IP used for login throttling; honours `trusted_proxies` the same way /// as the per-IP token bucket and the security telemetry. -fn login_client_ip( +pub(crate) fn login_client_ip( config: &AppConfig, extensions: &axum::http::Extensions, headers: &axum::http::HeaderMap, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index d3d5257..d75a5c4 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -657,6 +657,12 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic .nest("/api", api_router) .route("/", get(crate::ui::serve_ui)) .fallback(crate::ui::serve_ui); + // Optional CalDAV / CardDAV server under /dav. + let inner = if config.dav.enabled { + inner.merge(crate::dav::routes(&config)) + } else { + inner + }; let router = match config.normalized_base_path() { "/" => inner, @@ -668,6 +674,13 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic } }; + // RFC 6764 discovery always lives at the host root, also behind a base path. + let router = if config.dav.enabled { + router.merge(crate::dav::well_known_routes()) + } else { + router + }; + let router = if config.diagnostics_endpoint.enabled { let system_diagnostics_dependencies = Arc::new(system_diagnostics::SystemDiagnosticsDependencies::new(