1 parent
1b12899ff6
commit
a3ac2a68fe
33 files changed
+5422
-28
No files matched your search
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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('#') {
|
||||
|
||||
@@ -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(¤t));
|
||||
current.clear();
|
||||
} else {
|
||||
current.push(ch);
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
current.push('\\');
|
||||
}
|
||||
parts.push(unescape_text(¤t));
|
||||
parts
|
||||
}
|
||||
|
||||
/// Fold a logical line at 75 octets without splitting UTF-8 characters.
|
||||
pub fn fold(line: &str) -> String {
|
||||
if line.len() <= 75 {
|
||||
return line.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(line.len() + line.len() / 70 * 3);
|
||||
let mut width = 0usize;
|
||||
let mut limit = 75usize;
|
||||
for ch in line.chars() {
|
||||
let len = ch.len_utf8();
|
||||
if width + len > limit {
|
||||
out.push_str("\r\n ");
|
||||
width = 0;
|
||||
limit = 74;
|
||||
}
|
||||
out.push(ch);
|
||||
width += len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Join logical lines into a folded CRLF document.
|
||||
pub fn join(lines: &[String]) -> String {
|
||||
let mut out = String::new();
|
||||
for line in lines {
|
||||
out.push_str(&fold(line));
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Name of a `BEGIN:`/`END:` line, upper-cased.
|
||||
pub fn component_marker(line: &str) -> Option<(bool, String)> {
|
||||
let upper = line.trim_end().to_ascii_uppercase();
|
||||
if let Some(name) = upper.strip_prefix("BEGIN:") {
|
||||
Some((true, name.trim().to_string()))
|
||||
} else {
|
||||
upper
|
||||
.strip_prefix("END:")
|
||||
.map(|name| (false, name.trim().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unfolds_and_parses_parameters() {
|
||||
let lines = unfold(
|
||||
"ATTENDEE;CN=\"Doe, Jane\";ROLE=REQ-PARTICIPANT:mailto:j\r\n ane@example.com\r\n",
|
||||
);
|
||||
assert_eq!(lines.len(), 1);
|
||||
let line = parse_line(&lines[0]).unwrap();
|
||||
assert_eq!(line.name, "ATTENDEE");
|
||||
assert_eq!(line.param("CN"), Some("Doe, Jane"));
|
||||
assert_eq!(line.value, "mailto:jane@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_prefix_and_type_lists() {
|
||||
let line = parse_line("item1.EMAIL;type=INTERNET,pref:a@b.c").unwrap();
|
||||
assert_eq!(line.name, "EMAIL");
|
||||
assert!(line.param_has("TYPE", "PREF"));
|
||||
let bare = parse_line("TEL;CELL:+48 123").unwrap();
|
||||
assert!(bare.param_has("TYPE", "CELL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_roundtrip() {
|
||||
let text = "a;b,c\\d\ne";
|
||||
assert_eq!(unescape_text(&escape_text(text)), text);
|
||||
assert_eq!(
|
||||
split_structured("Doe;Jane\\;X;;"),
|
||||
vec!["Doe", "Jane;X", "", ""]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folds_long_utf8_lines() {
|
||||
let line = format!("SUMMARY:{}", "zażółć gęślą jaźń ".repeat(10));
|
||||
let folded = fold(&line);
|
||||
for part in folded.split("\r\n") {
|
||||
assert!(part.len() <= 75);
|
||||
}
|
||||
assert_eq!(unfold(&folded).join(""), line);
|
||||
}
|
||||
}
|
||||
+1089
File diff suppressed because it is too large.
Load diff
+1471
File diff suppressed because it is too large.
Load diff
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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", ¤t.company, &wanted.company, true),
|
||||
("TITLE", ¤t.job_title, &wanted.job_title, true),
|
||||
("NOTE", ¤t.notes, &wanted.notes, true),
|
||||
("URL", ¤t.website, &wanted.website, false),
|
||||
("BDAY", ¤t.birthday, &wanted.birthday, false),
|
||||
] {
|
||||
if old != new {
|
||||
drop.push(name);
|
||||
add.extend(single(name, new, escape));
|
||||
}
|
||||
}
|
||||
add.push(format!("REV:{}", Utc::now().format("%Y%m%dT%H%M%SZ")));
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut inside = false;
|
||||
for line in content::unfold(raw) {
|
||||
if let Some((begin, name)) = content::component_marker(&line)
|
||||
&& name == "VCARD"
|
||||
{
|
||||
if !begin && inside {
|
||||
out.append(&mut add);
|
||||
}
|
||||
inside = begin;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if inside
|
||||
&& let Some(parsed) = content::parse_line(&line)
|
||||
&& drop.contains(&parsed.name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
Some(content::join(&out))
|
||||
}
|
||||
|
||||
/// Evaluate an RFC 6352 `<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
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
// Keep CR of CRLF line endings in calendar/address data: XML parsers
|
||||
// normalize a literal CR away, a character reference survives.
|
||||
'\r' => out.push_str(" "),
|
||||
// Characters that are not allowed in XML 1.0 documents.
|
||||
c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Which properties a PROPFIND / REPORT asks for.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PropRequest {
|
||||
AllProp,
|
||||
PropName,
|
||||
Props(Vec<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&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
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
mod auth;
|
||||
mod calendar;
|
||||
mod config;
|
||||
mod dav;
|
||||
mod db;
|
||||
mod email_theme;
|
||||
mod error;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user