v0.2.30
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-08 07:32:12 +01:00
1 parent 1b12899ff6
commit a3ac2a68fe
33 files changed
+5422 -28

No files matched your search

+4
View File
@@ -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.
Generated
+39 -1
View File
@@ -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"
+7 -1
View File
@@ -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"
+2 -2
View File
@@ -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.
+8 -4
View File
@@ -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 `<base_path>/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
+8 -5
View File
@@ -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
+21
View File
@@ -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/<login>/default/
# https://mail.example.com/dav/addressbooks/<login>/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),
+147
View File
@@ -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 `<base_path>/dav` (with the default base path: `https://mail.example.com/dav/`):
| Resource | Path |
| :--- | :--- |
| Service root (for discovery) | `/dav/` |
| Principal | `/dav/principals/<login>/` |
| Calendar home | `/dav/calendars/<login>/` |
| Calendar | `/dav/calendars/<login>/default/` |
| Address book home | `/dav/addressbooks/<login>/` |
| Address book | `/dav/addressbooks/<login>/default/` |
`/.well-known/caldav` and `/.well-known/carddav` redirect to `<base_path>/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.
`<login>` 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 `<id>.ics` / `<id>.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.
+1
View File
@@ -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)
+8 -2
View File
@@ -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 `<base_path>/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
+12 -1
View File
@@ -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 `<id>.ics` / `<id>.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
+15
View File
@@ -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);
+9 -3
View File
@@ -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);
+15
View File
@@ -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);
+9 -3
View File
@@ -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);
@@ -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);
+7 -3
View File
@@ -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);
+22
View File
@@ -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"),
+62
View File
@@ -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 `<base_path>/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('#') {
+282
View File
@@ -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<String>)>,
/// 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<String> {
let mut lines: Vec<String> = 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<Line> {
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<String> {
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(&current));
current.clear();
} else {
current.push(ch);
}
}
if escaped {
current.push('\\');
}
parts.push(unescape_text(&current));
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);
}
}
+1089
View File
File diff suppressed because it is too large. Load diff
+1471
View File
File diff suppressed because it is too large. Load diff
+655
View File
@@ -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#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>"#;
const AP_COLLECTIONS: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:resourcetype/><d:displayname/><cs:getctag/><d:sync-token/><c:supported-calendar-component-set/></d:prop>
</d:propfind>"#;
const AP_CTAG: &str = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/><d:sync-token/></d:prop></d:propfind>"#;
const AP_LIST_CARDS: &str = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
fn ap_calendar_query(since: Option<&str>) -> String {
let range = since
.map(|s| format!("<c:time-range start=\"{s}\"/>"))
.unwrap_or_default();
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">{range}</c:comp-filter></c:comp-filter></c:filter>
</c:calendar-query>"#
)
}
fn ap_multiget(hrefs: &[String], cards: bool) -> String {
let hs: String = hrefs
.iter()
.map(|h| format!("<d:href>{}</d:href>", xml::escape(h)))
.collect();
if cards {
format!(
r#"<?xml version="1.0" encoding="utf-8"?><c:addressbook-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop>{hs}</c:addressbook-multiget>"#
)
} else {
format!(
r#"<?xml version="1.0" encoding="utf-8"?><c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop>{hs}</c:calendar-multiget>"#
)
}
}
fn ap_search_cards(text: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
<d:prop><d:getetag/><c:address-data/></d:prop>
<c:filter test="anyof">
<c:prop-filter name="FN"><c:text-match collation="i;unicode-casemap" match-type="contains">{text}</c:text-match></c:prop-filter>
<c:prop-filter name="EMAIL"><c:text-match collation="i;unicode-casemap" match-type="contains">{text}</c:text-match></c:prop-filter>
<c:prop-filter name="N"><c:text-match collation="i;unicode-casemap" match-type="contains">{text}</c:text-match></c:prop-filter>
</c:filter>
</c:addressbook-query>"#
)
}
/// Pull `<tag>value</tag>` values out of a multistatus body (namespace-agnostic).
fn values(body: &str, local: &str) -> Vec<String> {
let doc = xml::parse(body.as_bytes()).unwrap().unwrap();
let mut out = Vec::new();
fn walk(e: &Element, local: &str, out: &mut Vec<String>) {
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#"<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#;
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#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cr="urn:ietf:params:xml:ns:carddav"><d:prop><c:calendar-home-set/><cr:addressbook-home-set/><c:calendar-user-address-set/></d:prop></d:propfind>"#;
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("<c:calendar/>"));
assert!(xml.contains(r#"<c:comp name="VEVENT"/>"#));
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("<cr:addressbook/>"));
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("<d:resourcetype/>"));
// 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#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
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#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>Work</d:displayname></d:prop></d:set></d:propertyupdate>"#;
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())
);
}
+667
View File
@@ -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<String>,
pub phone: String,
pub phones: Vec<String>,
pub company: String,
pub job_title: String,
pub notes: String,
pub address: String,
pub addresses: Vec<String>,
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<String> {
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<String> {
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<String> {
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<String> {
let mut out: Vec<String> = 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<u8>)>,
}
fn card_lines(text: &str) -> Result<Vec<Line>, 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<String> {
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<String> = 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::<Vec<_>>()
.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<u8>)> {
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<ParsedCard, String> {
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::<Vec<_>>()
.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>| -> (String, Vec<String>) {
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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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<String>| {
v.into_iter()
.map(|s| s.to_ascii_lowercase())
.collect::<Vec<_>>()
};
let mut drop: Vec<&str> = vec!["REV"];
let mut add: Vec<String> = 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", &current.company, &wanted.company, true),
("TITLE", &current.job_title, &wanted.job_title, true),
("NOTE", &current.notes, &wanted.notes, true),
("URL", &current.website, &wanted.website, false),
("BDAY", &current.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 `<filter>` 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<String> = 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#"<c:filter xmlns:c="urn:ietf:params:xml:ns:carddav" test="anyof">
<c:prop-filter name="FN"><c:text-match collation="i;unicode-casemap" match-type="contains">nowak</c:text-match></c:prop-filter>
<c:prop-filter name="EMAIL"><c:text-match match-type="contains">zzz</c:text-match></c:prop-filter>
</c:filter>"#,
)
.unwrap()
.unwrap();
assert!(matches_filter(PHONE_CARD, Some(&filter)));
let vcf = contact_to_vcard(&contact());
assert!(!matches_filter(&vcf, Some(&filter)));
}
}
+374
View File
@@ -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!("<x:{} xmlns:x=\"{}\"/>", 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<Element>,
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<Item = &'a Element> + '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<Option<Element>, 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<Element> = Vec::new();
let mut root: Option<Element> = 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<Element, String> {
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: 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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
// 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("&#13;"),
// 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<QName>),
}
/// Extract the requested properties from a `<prop>` / `<allprop>` / `<propname>` 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#"<?xml version="1.0" encoding="utf-8"?>"#);
body.push_str(&format!("<d:multistatus {ROOT_XMLNS}>"));
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("<d:response><d:href>");
self.body.push_str(&escape(href));
self.body.push_str("</d:href>");
if !found.is_empty() || missing.is_empty() {
self.body.push_str("<d:propstat><d:prop>");
for prop in found {
self.body.push_str(prop);
}
self.body
.push_str("</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>");
}
if !missing.is_empty() {
self.body.push_str("<d:propstat><d:prop>");
for name in missing {
self.body.push_str(&name.empty_element());
}
self.body
.push_str("</d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>");
}
self.body.push_str("</d:response>");
}
/// 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("<d:response><d:href>");
self.body.push_str(&escape(href));
self.body.push_str("</d:href><d:propstat><d:prop>");
for name in names {
self.body.push_str(&name.empty_element());
}
self.body.push_str("</d:prop><d:status>HTTP/1.1 ");
self.body.push_str(status);
self.body.push_str("</d:status></d:propstat></d:response>");
}
/// 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("<d:response><d:href>");
self.body.push_str(&escape(href));
self.body.push_str("</d:href><d:status>HTTP/1.1 ");
self.body.push_str(status);
self.body.push_str("</d:status></d:response>");
}
pub fn finish(mut self) -> String {
self.body.push_str("</d:multistatus>");
self.body
}
}
/// `<d:error>` body for failed preconditions.
pub fn error_body(inner: &str) -> String {
format!(r#"<?xml version="1.0" encoding="utf-8"?><d:error {ROOT_XMLNS}>{inner}</d:error>"#)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_propfind_with_namespaces_and_entities() {
let body = br#"<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:resourcetype/><d:displayname/><cs:getctag/><c:supported-calendar-component-set/></d:prop>
</d:propfind>"#;
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#"<a:href xmlns:a="DAV:">/x/a&amp;b%20c.ics</a:href>"#)
.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!("<a:x xmlns:a=\"DAV:\">{}</a:x>", 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"<a><b></a>").is_err());
}
#[test]
fn unknown_namespace_is_declared_inline() {
let name = QName::new("urn:example", "foo");
assert_eq!(name.empty_element(), r#"<x:foo xmlns:x="urn:example"/>"#);
}
}
+6 -1
View File
@@ -148,7 +148,9 @@ pub struct MeetingTemplate {
pub created_at: String,
}
fn row_to_event(row: &crate::db::pool::Row<'_>) -> Result<CalendarEvent, crate::db::pool::DbError> {
pub(crate) fn row_to_event(
row: &crate::db::pool::Row<'_>,
) -> Result<CalendarEvent, crate::db::pool::DbError> {
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)
}
+17 -1
View File
@@ -33,7 +33,9 @@ pub struct Contact {
pub has_photo: bool,
}
fn row_to_contact(row: &crate::db::pool::Row<'_>) -> Result<Contact, crate::db::pool::DbError> {
pub(crate) fn row_to_contact(
row: &crate::db::pool::Row<'_>,
) -> Result<Contact, crate::db::pool::DbError> {
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}"))?;
+401
View File
@@ -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
//! `<id>.ics` / `<id>.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<String>,
pub dav_data: Option<String>,
}
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<String>,
pub dav_data: Option<String>,
}
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<DavEvent, DbError> {
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<DavContact, DbError> {
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<T>(result: Result<T, DbError>, what: &str) -> Result<Option<T>, 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 (`<id>.ics` -> `<id>`).
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<Vec<DavEvent>, 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<Option<DavEvent>, 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<Option<DavEvent>, 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<Option<DavEvent>, 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<String, String> {
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<Vec<DavContact>, 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<Option<DavContact>, 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<Option<DavContact>, 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"));
}
}
+1
View File
@@ -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;
+29
View File
@@ -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<String>, Option<String>) = 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<i64>, Option<i64>) = conn
.query_row(
"SELECT default_reminder_minutes, default_extra_reminder_minutes FROM u_test__calendar_settings WHERE id=1",
+1
View File
@@ -1,6 +1,7 @@
mod auth;
mod calendar;
mod config;
mod dav;
mod db;
mod email_theme;
mod error;
+1 -1
View File
@@ -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,
+13
View File
@@ -657,6 +657,12 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
.nest("/api", api_router)
.route("/", get(crate::ui::serve_ui))
.fallback(crate::ui::serve_ui);
// Optional CalDAV / CardDAV server under <base_path>/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<AppConfig>, 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(