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

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

No files matched your search

+62
View File
@@ -130,6 +130,54 @@ impl Default for DiagnosticsEndpointConfig {
}
}
/// Built-in CalDAV / CardDAV server (`[dav]`). Off by default.
#[derive(Debug, Deserialize, Clone)]
pub struct DavConfig {
/// Serve `<base_path>/dav/` (CalDAV + CardDAV) and `/.well-known/caldav|carddav`.
#[serde(default)]
pub enabled: bool,
/// How long a successful HTTP Basic login is remembered before the IMAP
/// password is checked again (DAV clients authenticate on every request).
#[serde(default = "default_dav_auth_cache_secs")]
pub auth_cache_secs: u64,
/// Largest accepted calendar object / vCard in bytes.
#[serde(default = "default_dav_max_resource_size")]
pub max_resource_size: usize,
/// Display names of the single calendar and address book of every account.
#[serde(default = "default_dav_calendar_name")]
pub calendar_name: String,
#[serde(default = "default_dav_addressbook_name")]
pub addressbook_name: String,
}
impl Default for DavConfig {
fn default() -> Self {
Self {
enabled: false,
auth_cache_secs: default_dav_auth_cache_secs(),
max_resource_size: default_dav_max_resource_size(),
calendar_name: default_dav_calendar_name(),
addressbook_name: default_dav_addressbook_name(),
}
}
}
fn default_dav_auth_cache_secs() -> u64 {
300
}
fn default_dav_max_resource_size() -> usize {
8 * 1024 * 1024
}
fn default_dav_calendar_name() -> String {
"Calendar".to_string()
}
fn default_dav_addressbook_name() -> String {
"Contacts".to_string()
}
#[derive(Debug, Deserialize, Clone)]
pub struct SearchConfig {
#[serde(default = "default_search_backend")]
@@ -395,6 +443,9 @@ pub struct AppConfig {
#[serde(default)]
pub diagnostics_endpoint: DiagnosticsEndpointConfig,
#[serde(default)]
pub dav: DavConfig,
#[serde(default)]
pub login_rate_limit: LoginRateLimitConfig,
@@ -773,6 +824,7 @@ impl Default for AppConfig {
search: SearchConfig::default(),
sync: SyncConfig::default(),
diagnostics_endpoint: DiagnosticsEndpointConfig::default(),
dav: DavConfig::default(),
login_rate_limit: LoginRateLimitConfig::default(),
session_timeout_hours: default_session_timeout_hours(),
remember_me_enabled: default_remember_me_enabled(),
@@ -1073,6 +1125,16 @@ impl AppConfig {
return Err("Redis sessions require REDIS_URL or REDIS_SENTINEL_NODES + REDIS_SENTINEL_MASTER".to_string());
}
}
if self.dav.enabled {
if self.dav.max_resource_size < 1024 || self.dav.max_resource_size > 64 * 1024 * 1024 {
return Err(
"dav.max_resource_size must be between 1024 and 67108864 bytes".to_string(),
);
}
if self.dav.auth_cache_secs > 86_400 {
return Err("dav.auth_cache_secs must be at most 86400".to_string());
}
}
if self.diagnostics_endpoint.enabled {
let path = self.diagnostics_endpoint.path.trim();
if !path.starts_with("/api/system/") || path.contains('?') || path.contains('#') {
+282
View File
@@ -0,0 +1,282 @@
//! Content-line handling shared by iCalendar (RFC 5545) and vCard (RFC 6350).
/// One unfolded content line.
#[derive(Debug, Clone)]
pub struct Line {
/// Upper-case property name without a vCard group prefix (`item1.EMAIL` -> `EMAIL`).
pub name: String,
/// Upper-case parameter names with their raw (unquoted) values.
pub params: Vec<(String, Vec<String>)>,
/// Raw value, still escaped.
pub value: String,
}
impl Line {
pub fn param(&self, name: &str) -> Option<&str> {
self.params
.iter()
.find(|(key, _)| key == name)
.and_then(|(_, values)| values.first())
.map(String::as_str)
}
/// True if any value of parameter `name` equals `needle` (case-insensitive),
/// also inside comma separated lists such as `TYPE=INTERNET,PREF`.
pub fn param_has(&self, name: &str, needle: &str) -> bool {
self.params
.iter()
.filter(|(key, _)| key == name)
.flat_map(|(_, values)| values.iter())
.flat_map(|value| value.split(','))
.any(|value| value.trim().eq_ignore_ascii_case(needle))
}
pub fn text(&self) -> String {
unescape_text(&self.value)
}
}
/// Split a document into unfolded logical lines (handles CRLF and bare LF).
pub fn unfold(text: &str) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for raw in text.split('\n') {
let raw = raw.strip_suffix('\r').unwrap_or(raw);
if let Some(rest) = raw.strip_prefix(' ').or_else(|| raw.strip_prefix('\t'))
&& let Some(last) = lines.last_mut()
{
last.push_str(rest);
continue;
}
if raw.is_empty() {
continue;
}
lines.push(raw.to_string());
}
lines
}
/// Parse one unfolded content line. Returns `None` for lines without a `:`.
pub fn parse_line(line: &str) -> Option<Line> {
let bytes = line.as_bytes();
let mut i = 0usize;
while i < bytes.len() && bytes[i] != b';' && bytes[i] != b':' {
i += 1;
}
if i >= bytes.len() {
return None;
}
let full_name = &line[..i];
let name = full_name
.rsplit('.')
.next()
.unwrap_or(full_name)
.to_ascii_uppercase();
if name.is_empty() {
return None;
}
let mut params = Vec::new();
while i < bytes.len() && bytes[i] == b';' {
i += 1;
let key_start = i;
while i < bytes.len() && bytes[i] != b'=' && bytes[i] != b';' && bytes[i] != b':' {
i += 1;
}
let key = line[key_start..i].to_ascii_uppercase();
let mut values = Vec::new();
if i < bytes.len() && bytes[i] == b'=' {
i += 1;
loop {
if i < bytes.len() && bytes[i] == b'"' {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
values.push(line[start..i.min(bytes.len())].to_string());
if i < bytes.len() {
i += 1;
}
} else {
let start = i;
while i < bytes.len()
&& bytes[i] != b','
&& bytes[i] != b';'
&& bytes[i] != b':'
{
i += 1;
}
values.push(line[start..i].to_string());
}
if i < bytes.len() && bytes[i] == b',' {
i += 1;
continue;
}
break;
}
} else if !key.is_empty() {
// vCard 2.1 style bare parameter (e.g. `TEL;CELL:`), treat as TYPE.
params.push(("TYPE".to_string(), vec![key]));
continue;
}
if !key.is_empty() {
params.push((key, values));
}
}
if i >= bytes.len() || bytes[i] != b':' {
return None;
}
Some(Line {
name,
params,
value: line[i + 1..].to_string(),
})
}
/// Unescape a TEXT value (`\n`, `\,`, `\;`, `\\`).
pub fn unescape_text(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('n') | Some('N') => out.push('\n'),
Some(other) => out.push(other),
None => out.push('\\'),
}
} else {
out.push(ch);
}
}
out
}
/// Escape a TEXT value.
pub fn escape_text(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 8);
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
';' => out.push_str("\\;"),
',' => out.push_str("\\,"),
'\n' => out.push_str("\\n"),
'\r' => {}
c => out.push(c),
}
}
out
}
/// Split a structured value (e.g. `N`, `ADR`) on unescaped `;`.
pub fn split_structured(value: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut escaped = false;
for ch in value.chars() {
if escaped {
current.push('\\');
current.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == ';' {
parts.push(unescape_text(&current));
current.clear();
} else {
current.push(ch);
}
}
if escaped {
current.push('\\');
}
parts.push(unescape_text(&current));
parts
}
/// Fold a logical line at 75 octets without splitting UTF-8 characters.
pub fn fold(line: &str) -> String {
if line.len() <= 75 {
return line.to_string();
}
let mut out = String::with_capacity(line.len() + line.len() / 70 * 3);
let mut width = 0usize;
let mut limit = 75usize;
for ch in line.chars() {
let len = ch.len_utf8();
if width + len > limit {
out.push_str("\r\n ");
width = 0;
limit = 74;
}
out.push(ch);
width += len;
}
out
}
/// Join logical lines into a folded CRLF document.
pub fn join(lines: &[String]) -> String {
let mut out = String::new();
for line in lines {
out.push_str(&fold(line));
out.push_str("\r\n");
}
out
}
/// Name of a `BEGIN:`/`END:` line, upper-cased.
pub fn component_marker(line: &str) -> Option<(bool, String)> {
let upper = line.trim_end().to_ascii_uppercase();
if let Some(name) = upper.strip_prefix("BEGIN:") {
Some((true, name.trim().to_string()))
} else {
upper
.strip_prefix("END:")
.map(|name| (false, name.trim().to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unfolds_and_parses_parameters() {
let lines = unfold(
"ATTENDEE;CN=\"Doe, Jane\";ROLE=REQ-PARTICIPANT:mailto:j\r\n ane@example.com\r\n",
);
assert_eq!(lines.len(), 1);
let line = parse_line(&lines[0]).unwrap();
assert_eq!(line.name, "ATTENDEE");
assert_eq!(line.param("CN"), Some("Doe, Jane"));
assert_eq!(line.value, "mailto:jane@example.com");
}
#[test]
fn group_prefix_and_type_lists() {
let line = parse_line("item1.EMAIL;type=INTERNET,pref:a@b.c").unwrap();
assert_eq!(line.name, "EMAIL");
assert!(line.param_has("TYPE", "PREF"));
let bare = parse_line("TEL;CELL:+48 123").unwrap();
assert!(bare.param_has("TYPE", "CELL"));
}
#[test]
fn escapes_roundtrip() {
let text = "a;b,c\\d\ne";
assert_eq!(unescape_text(&escape_text(text)), text);
assert_eq!(
split_structured("Doe;Jane\\;X;;"),
vec!["Doe", "Jane;X", "", ""]
);
}
#[test]
fn folds_long_utf8_lines() {
let line = format!("SUMMARY:{}", "zażółć gęślą jaźń ".repeat(10));
let folded = fold(&line);
for part in folded.split("\r\n") {
assert!(part.len() <= 75);
}
assert_eq!(unfold(&folded).join(""), line);
}
}
+1089
View File
File diff suppressed because it is too large. Load diff
+1471
View File
File diff suppressed because it is too large. Load diff
+655
View File
@@ -0,0 +1,655 @@
//! End-to-end tests of the DAV endpoint. The request bodies are the ones sent by
//! the ActivePush CalDAV/CardDAV client (`src/backend/dav.rs`) plus a few generic
//! client requests (well-known discovery, principal lookup).
use super::*;
use axum::body::Body;
use axum::http::Request as HttpRequest;
use http_body_util::BodyExt;
use tempfile::TempDir;
use tower::ServiceExt;
const LOGIN: &str = "jan@example.com";
const PASSWORD: &str = "secret";
struct TestApp {
app: Router,
_dir: TempDir,
data_dir: String,
root: String,
}
fn test_app(base_path: Option<&str>) -> TestApp {
let dir = TempDir::new().unwrap();
let data_dir = dir.path().to_str().unwrap().to_string();
let mut config = AppConfig {
imap_host: Some("imap.example.com".into()),
data_dir: data_dir.clone(),
base_path: base_path.map(str::to_string),
..AppConfig::default()
};
config.dav.enabled = true;
let config = Arc::new(config);
crate::auth::user_data::provision_user_database(
&data_dir,
&crate::auth::user_data::hash_email(LOGIN),
)
.unwrap();
// Credentials are pre-validated so no IMAP server is needed.
let cache = Arc::new(DavAuthCache::default());
cache.remember(DavAuthCache::key(LOGIN, PASSWORD), Duration::from_secs(600));
let inner = routes_with_cache(&config, cache);
let app = match config.normalized_base_path() {
"/" => inner,
base => Router::new().nest(base, inner),
}
.merge(well_known_routes())
.layer(Extension(config.clone()));
TestApp {
app,
_dir: dir,
root: dav_root(&config),
data_dir,
}
}
impl TestApp {
fn conn(&self) -> Connection {
crate::db::pool::open_user_db(&self.data_dir, &crate::auth::user_data::hash_email(LOGIN))
.unwrap()
}
async fn send(
&self,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: &str,
) -> (StatusCode, HeaderMap, String) {
self.send_as(method, path, headers, body, Some((LOGIN, PASSWORD)))
.await
}
async fn send_as(
&self,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: &str,
credentials: Option<(&str, &str)>,
) -> (StatusCode, HeaderMap, String) {
let uri = if path.starts_with("/.well-known") {
path.to_string()
} else {
format!("{}{path}", self.root)
};
let mut builder = HttpRequest::builder().method(method).uri(uri);
if let Some((user, pass)) = credentials {
let token = base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}"));
builder = builder.header("authorization", format!("Basic {token}"));
}
for (name, value) in headers {
builder = builder.header(*name, *value);
}
let response = self
.app
.clone()
.oneshot(builder.body(Body::from(body.to_string())).unwrap())
.await
.unwrap();
let status = response.status();
let headers = response.headers().clone();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
(
status,
headers,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
}
const XML: (&str, &str) = ("content-type", "application/xml; charset=utf-8");
// ---- request bodies copied from ActivePush src/backend/dav.rs
const AP_CHECK: &str = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>"#;
const AP_COLLECTIONS: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:resourcetype/><d:displayname/><cs:getctag/><d:sync-token/><c:supported-calendar-component-set/></d:prop>
</d:propfind>"#;
const AP_CTAG: &str = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/><d:sync-token/></d:prop></d:propfind>"#;
const AP_LIST_CARDS: &str = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
fn ap_calendar_query(since: Option<&str>) -> String {
let range = since
.map(|s| format!("<c:time-range start=\"{s}\"/>"))
.unwrap_or_default();
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">{range}</c:comp-filter></c:comp-filter></c:filter>
</c:calendar-query>"#
)
}
fn ap_multiget(hrefs: &[String], cards: bool) -> String {
let hs: String = hrefs
.iter()
.map(|h| format!("<d:href>{}</d:href>", xml::escape(h)))
.collect();
if cards {
format!(
r#"<?xml version="1.0" encoding="utf-8"?><c:addressbook-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop>{hs}</c:addressbook-multiget>"#
)
} else {
format!(
r#"<?xml version="1.0" encoding="utf-8"?><c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop>{hs}</c:calendar-multiget>"#
)
}
}
fn ap_search_cards(text: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
<d:prop><d:getetag/><c:address-data/></d:prop>
<c:filter test="anyof">
<c:prop-filter name="FN"><c:text-match collation="i;unicode-casemap" match-type="contains">{text}</c:text-match></c:prop-filter>
<c:prop-filter name="EMAIL"><c:text-match collation="i;unicode-casemap" match-type="contains">{text}</c:text-match></c:prop-filter>
<c:prop-filter name="N"><c:text-match collation="i;unicode-casemap" match-type="contains">{text}</c:text-match></c:prop-filter>
</c:filter>
</c:addressbook-query>"#
)
}
/// Pull `<tag>value</tag>` values out of a multistatus body (namespace-agnostic).
fn values(body: &str, local: &str) -> Vec<String> {
let doc = xml::parse(body.as_bytes()).unwrap().unwrap();
let mut out = Vec::new();
fn walk(e: &Element, local: &str, out: &mut Vec<String>) {
if e.name == local {
out.push(e.text.clone());
}
for c in &e.children {
walk(c, local, out);
}
}
walk(&doc, local, &mut out);
out
}
fn ctag(xml: &str) -> String {
values(xml, "getctag")
.into_iter()
.find(|v| !v.is_empty())
.unwrap_or_default()
}
fn event_ics(uid: &str, summary: &str) -> String {
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//ActivePush//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\
DTSTAMP:20261101T000000Z\r\nDTSTART;TZID=Europe/Warsaw:20261110T090000\r\nDTEND;TZID=Europe/Warsaw:20261110T100000\r\n\
SUMMARY:{summary}\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
)
}
#[tokio::test]
async fn options_is_public_and_everything_else_needs_basic_auth() {
let t = test_app(None);
let (status, headers, _) = t.send_as("OPTIONS", "/", &[], "", None).await;
assert_eq!(status, StatusCode::OK);
assert!(headers["dav"].to_str().unwrap().contains("calendar-access"));
let (status, headers, _) = t.send_as("PROPFIND", "/", &[], "", None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(headers.contains_key("www-authenticate"));
// Another account's collection is refused even with valid credentials.
let (status, _, _) = t
.send(
"PROPFIND",
"/calendars/someone@example.com/",
&[("depth", "0")],
AP_CHECK,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn discovery_from_well_known_to_calendar_home() {
let t = test_app(Some("/mail"));
let (status, headers, _) = t.send("PROPFIND", "/.well-known/caldav", &[], "").await;
assert_eq!(status, StatusCode::MOVED_PERMANENTLY);
assert_eq!(headers["location"], "/mail/dav/");
let body =
r#"<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#;
let (status, _, xml) = t.send("PROPFIND", "/", &[("depth", "0"), XML], body).await;
assert_eq!(status, StatusCode::MULTI_STATUS);
assert_eq!(
values(&xml, "href")[1],
"/mail/dav/principals/jan@example.com/"
);
let body = r#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cr="urn:ietf:params:xml:ns:carddav"><d:prop><c:calendar-home-set/><cr:addressbook-home-set/><c:calendar-user-address-set/></d:prop></d:propfind>"#;
let (status, _, xml) = t
.send(
"PROPFIND",
"/principals/jan@example.com/",
&[("depth", "0"), XML],
body,
)
.await;
assert_eq!(status, StatusCode::MULTI_STATUS);
let hrefs = values(&xml, "href");
assert!(hrefs.contains(&"/mail/dav/calendars/jan@example.com/".to_string()));
assert!(hrefs.contains(&"/mail/dav/addressbooks/jan@example.com/".to_string()));
assert!(hrefs.contains(&"mailto:jan@example.com".to_string()));
}
#[tokio::test]
async fn activepush_calendar_sync_cycle() {
let t = test_app(None);
let home = "/calendars/jan@example.com/";
let collection = "/dav/calendars/jan@example.com/default/";
// check()
let (status, _, _) = t
.send("PROPFIND", home, &[("depth", "0"), XML], AP_CHECK)
.await;
assert_eq!(status.as_u16(), 207);
// An event created in the webmail before the phone syncs.
let webmail: crate::db::calendar::CreateEvent = serde_json::from_value(serde_json::json!({
"title": "Webmail meeting",
"start_time": "2026-11-12T13:00:00.000Z",
"end_time": "2026-11-12T14:00:00.000Z",
"reminder_minutes": 10
}))
.unwrap();
let webmail = crate::db::calendar::create_event(&t.conn(), &webmail).unwrap();
// collections(): home Depth 1 lists exactly one VEVENT calendar with a ctag.
let (status, _, xml) = t
.send("PROPFIND", home, &[("depth", "1"), XML], AP_COLLECTIONS)
.await;
assert_eq!(status.as_u16(), 207);
assert!(xml.contains("<c:calendar/>"));
assert!(xml.contains(r#"<c:comp name="VEVENT"/>"#));
assert!(values(&xml, "href").contains(&collection.to_string()));
let ctag0 = ctag(&xml);
assert!(ctag0.starts_with("rustbox-"));
// list(): calendar-query with a time-range returns href + etag.
let (status, _, xml) = t
.send(
"REPORT",
"/calendars/jan@example.com/default/",
&[("depth", "1"), XML],
&ap_calendar_query(Some("20261101T000000Z")),
)
.await;
assert_eq!(status.as_u16(), 207);
let hrefs = values(&xml, "href");
assert_eq!(hrefs, vec![format!("{collection}{}.ics", webmail.id)]);
assert_eq!(values(&xml, "getetag").len(), 1);
// Past range excludes it.
let (_, _, xml) = t
.send(
"REPORT",
"/calendars/jan@example.com/default/",
&[("depth", "1"), XML],
&ap_calendar_query(Some("20261201T000000Z")),
)
.await;
assert!(values(&xml, "href").is_empty());
// multiget(): the webmail event as iCalendar.
let (_, _, xml) = t
.send(
"REPORT",
"/calendars/jan@example.com/default/",
&[("depth", "1"), XML],
&ap_multiget(&hrefs, false),
)
.await;
let data = values(&xml, "calendar-data");
assert_eq!(data.len(), 1);
assert!(data[0].contains("SUMMARY:Webmail meeting"));
assert!(data[0].contains("TRIGGER:-PT10M"));
// put(): new event from the phone (If-None-Match: *).
let name = "4f9e7c1a-0000-4000-8000-000000000001.ics";
let path = format!("/calendars/jan@example.com/default/{name}");
let ics = event_ics("phone-uid-1", "Dentist");
let (status, headers, _) = t
.send(
"PUT",
&path,
&[
("content-type", "text/calendar; charset=utf-8"),
("if-none-match", "*"),
],
&ics,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let etag1 = headers["etag"].to_str().unwrap().to_string();
// Creating it again must fail.
let (status, _, _) = t.send("PUT", &path, &[("if-none-match", "*")], &ics).await;
assert_eq!(status, StatusCode::PRECONDITION_FAILED);
// The webmail sees the phone event, converted from Europe/Warsaw to UTC.
let stored = crate::db::calendar::find_event_by_source_uid(&t.conn(), "phone-uid-1")
.unwrap()
.unwrap();
assert_eq!(stored.title, "Dentist");
assert_eq!(stored.start_time, "2026-11-10T08:00:00Z");
assert_eq!(stored.reminder_minutes, Some(15));
assert_eq!(stored.source, "caldav");
// ctag() changed.
let (_, _, xml) = t
.send(
"PROPFIND",
"/calendars/jan@example.com/default/",
&[("depth", "0"), XML],
AP_CTAG,
)
.await;
let ctag1 = ctag(&xml);
assert_ne!(ctag0, ctag1);
// get(): byte-identical body and the same ETag.
let (status, headers, body) = t.send("GET", &path, &[], "").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, ics);
assert_eq!(headers["etag"].to_str().unwrap(), etag1);
// Update with a stale ETag fails, with the current one succeeds.
let updated = event_ics("phone-uid-1", "Dentist (moved)");
let (status, _, _) = t
.send("PUT", &path, &[("if-match", "\"stale\"")], &updated)
.await;
assert_eq!(status, StatusCode::PRECONDITION_FAILED);
let (status, headers, _) = t
.send("PUT", &path, &[("if-match", etag1.as_str())], &updated)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let etag2 = headers["etag"].to_str().unwrap().to_string();
assert_ne!(etag1, etag2);
// A webmail edit is visible to the phone, keeping the time zone.
let edit: crate::db::calendar::UpdateEvent =
serde_json::from_value(serde_json::json!({"location": "Clinic"})).unwrap();
crate::db::calendar::update_event(&t.conn(), &stored.id, &edit).unwrap();
let (_, headers, body) = t.send("GET", &path, &[], "").await;
assert!(body.contains("LOCATION:Clinic\r\n"));
assert!(body.contains("DTSTART;TZID=Europe/Warsaw:20261110T090000\r\n"));
assert_ne!(headers["etag"].to_str().unwrap(), etag2);
// VTODO is not accepted (the calendar only advertises VEVENT).
let todo = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:x\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
let (status, _, body) = t
.send(
"PUT",
"/calendars/jan@example.com/default/t1.ics",
&[],
todo,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(body.contains("supported-calendar-component"));
// A second resource with the same UID is a conflict.
let (status, _, body) = t
.send(
"PUT",
"/calendars/jan@example.com/default/dup.ics",
&[],
&ics,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(body.contains("no-uid-conflict"));
// delete()
let (status, _, _) = t.send("DELETE", &path, &[], "").await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _, _) = t.send("GET", &path, &[], "").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(
crate::db::calendar::find_event_by_source_uid(&t.conn(), "phone-uid-1")
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn accepting_an_invitation_on_the_phone_adopts_the_webmail_event() {
let t = test_app(None);
let invitation: crate::db::calendar::CreateEvent = serde_json::from_value(serde_json::json!({
"title": "Board meeting",
"start_time": "2026-11-20T09:00:00Z",
"end_time": "2026-11-20T10:00:00Z",
"source": "invitation",
"source_uid": "inv-42@example.com"
}))
.unwrap();
let original = crate::db::calendar::create_event(&t.conn(), &invitation).unwrap();
let (status, _, _) = t
.send(
"PUT",
"/calendars/jan@example.com/default/accepted.ics",
&[("if-none-match", "*")],
&event_ics("inv-42@example.com", "Board meeting"),
)
.await;
assert_eq!(status, StatusCode::CREATED);
let all = crate::db::calendar::list_all_events(&t.conn()).unwrap();
assert_eq!(all.len(), 1, "no duplicate event");
assert_eq!(all[0].id, original.id);
assert_eq!(all[0].source, "invitation");
}
#[tokio::test]
async fn activepush_contacts_sync_cycle() {
let t = test_app(None);
let home = "/addressbooks/jan@example.com/";
let book = "/addressbooks/jan@example.com/default/";
let (status, _, xml) = t
.send("PROPFIND", home, &[("depth", "1"), XML], AP_COLLECTIONS)
.await;
assert_eq!(status.as_u16(), 207);
assert!(xml.contains("<cr:addressbook/>"));
assert!(values(&xml, "href").contains(&format!("/dav{book}")));
// A webmail contact is listed through PROPFIND Depth 1 (list() without comp).
let webmail = crate::db::contacts::Contact {
id: "w1".into(),
email: "boss@example.com".into(),
name: "The Boss".into(),
company: String::new(),
notes: String::new(),
phone: String::new(),
job_title: String::new(),
address: String::new(),
website: String::new(),
birthday: String::new(),
emails: vec![],
phones: vec![],
addresses: vec![],
is_favorite: true,
last_contacted: None,
contact_count: 3,
source: "manual".into(),
created_at: "2026-11-01 00:00:00".into(),
updated_at: "2026-11-01 00:00:00".into(),
has_photo: false,
};
crate::db::contacts::upsert_contact(&t.conn(), &webmail).unwrap();
let (_, _, xml) = t
.send("PROPFIND", book, &[("depth", "1"), XML], AP_LIST_CARDS)
.await;
let hrefs = values(&xml, "href");
assert_eq!(hrefs.len(), 2, "collection + one card: {xml}");
assert_eq!(hrefs[1], "/dav/addressbooks/jan@example.com/default/w1.vcf");
assert!(xml.contains("<d:resourcetype/>"));
// A phone contact without an e-mail address.
let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:p1\r\nFN:Plumber Pete\r\nN:Pete;Plumber;;;\r\nTEL;TYPE=CELL:+48 600 100 200\r\nEND:VCARD\r\n";
let (status, headers, _) = t
.send(
"PUT",
&format!("{book}p1.vcf"),
&[
("content-type", "text/vcard; charset=utf-8"),
("if-none-match", "*"),
],
card,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert!(headers.contains_key("etag"));
let stored = crate::db::dav::find_contact_by_name(&t.conn(), "p1.vcf")
.unwrap()
.unwrap();
assert_eq!(stored.contact.name, "Plumber Pete");
assert_eq!(stored.contact.phone, "+48 600 100 200");
assert_eq!(stored.contact.email, "");
// multiget() of both.
let hrefs = vec![
format!("/dav{book}w1.vcf"),
format!("/dav{book}p1.vcf"),
format!("/dav{book}missing.vcf"),
];
let (_, _, xml) = t
.send(
"REPORT",
book,
&[("depth", "1"), XML],
&ap_multiget(&hrefs, true),
)
.await;
let data = values(&xml, "address-data");
assert_eq!(data.len(), 2);
assert!(data[0].contains("FN:The Boss"));
assert_eq!(data[1], card);
assert!(xml.contains("404 Not Found"));
// search_cards() (GAL search).
let (status, _, xml) = t
.send(
"REPORT",
book,
&[("depth", "1"), XML],
&ap_search_cards("plumb"),
)
.await;
assert_eq!(status.as_u16(), 207);
let found = values(&xml, "address-data");
assert_eq!(found.len(), 1);
assert!(found[0].contains("Plumber Pete"));
// Webmail edit of the phone contact is patched into the stored vCard.
let mut edited = stored.contact.clone();
edited.email = "pete@plumbing.example".into();
crate::db::contacts::upsert_contact(&t.conn(), &edited).unwrap();
let (_, _, body) = t.send("GET", &format!("{book}p1.vcf"), &[], "").await;
assert!(body.contains("EMAIL;TYPE=INTERNET,PREF:pete@plumbing.example\r\n"));
assert!(body.contains("TEL;TYPE=CELL:+48 600 100 200\r\n"));
// Updating from the phone keeps webmail-only metadata (favourite, counters).
let (_, headers, _) = t.send("GET", &format!("{book}w1.vcf"), &[], "").await;
let etag = headers["etag"].to_str().unwrap().to_string();
let boss = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:w1\r\nFN:The Big Boss\r\nEMAIL:boss@example.com\r\nEND:VCARD\r\n";
let (status, _, _) = t
.send(
"PUT",
&format!("{book}w1.vcf"),
&[("if-match", etag.as_str())],
boss,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let boss_row = crate::db::contacts::get_contact(&t.conn(), "w1")
.unwrap()
.unwrap();
assert_eq!(boss_row.name, "The Big Boss");
assert!(boss_row.is_favorite);
assert_eq!(boss_row.contact_count, 3);
// Invalid card is rejected; delete works.
let (status, _, _) = t.send("PUT", &format!("{book}bad.vcf"), &[], "hello").await;
assert_eq!(status, StatusCode::FORBIDDEN);
let (status, _, _) = t.send("DELETE", &format!("{book}p1.vcf"), &[], "").await;
assert_eq!(status, StatusCode::NO_CONTENT);
assert!(
crate::db::contacts::get_contact(&t.conn(), &stored.contact.id)
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn unsupported_operations_are_refused_cleanly() {
let t = test_app(None);
let (status, _, _) = t
.send("MKCALENDAR", "/calendars/jan@example.com/work/", &[], "")
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
let sync = r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
let (status, _, body) = t
.send(
"REPORT",
"/calendars/jan@example.com/default/",
&[XML],
sync,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(body.contains("supported-report"));
let rename = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>Work</d:displayname></d:prop></d:set></d:propertyupdate>"#;
let (status, _, body) = t
.send(
"PROPPATCH",
"/calendars/jan@example.com/default/",
&[XML],
rename,
)
.await;
assert_eq!(status.as_u16(), 207);
assert!(body.contains("403 Forbidden"));
let (status, _, _) = t
.send(
"PROPFIND",
"/calendars/jan@example.com/other/",
&[("depth", "0")],
"",
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, _, _) = t
.send("DELETE", "/calendars/jan@example.com/default/", &[], "")
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[test]
fn etag_and_href_helpers() {
assert!(etag_matches("*", "\"a\""));
assert!(etag_matches("W/\"a\", \"b\"", "\"a\""));
assert!(!etag_matches("\"b\"", "\"a\""));
assert_eq!(
href_name("https://mail.example.com/dav/calendars/u/default/a%20b.ics").as_deref(),
Some("a b.ics")
);
assert_eq!(href_name("/dav/x/").as_deref(), Some("x"));
assert!(!valid_resource_name("../x"));
assert_eq!(
parse_target("/calendars/U@x/default/a.ics", "u@x"),
Target::Event("a.ics".into())
);
}
+667
View File
@@ -0,0 +1,667 @@
//! Mapping between vCards (RFC 2426 / RFC 6350) and RustBox contacts.
//!
//! Like calendar objects, uploaded vCards are stored verbatim (`contacts.dav_data`)
//! and decoded into the webmail columns; webmail edits are patched back into the
//! stored card property by property.
use base64::Engine as _;
use chrono::Utc;
use super::content::{self, Line, escape_text, split_structured};
use super::xml::{Element, NS_CARDDAV};
use crate::db::contacts::Contact;
pub const PRODID: &str = "-//RustBox//CardDAV//EN";
/// Largest embedded PHOTO imported into the webmail photo store.
const MAX_PHOTO_BYTES: usize = 3 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CardFields {
pub name: String,
pub email: String,
pub emails: Vec<String>,
pub phone: String,
pub phones: Vec<String>,
pub company: String,
pub job_title: String,
pub notes: String,
pub address: String,
pub addresses: Vec<String>,
pub website: String,
pub birthday: String,
}
impl CardFields {
pub fn from_contact(contact: &Contact) -> Self {
Self {
name: contact.name.trim().to_string(),
email: contact.email.trim().to_string(),
emails: clean_list(&contact.emails, &contact.email),
phone: contact.phone.trim().to_string(),
phones: clean_list(&contact.phones, &contact.phone),
company: contact.company.trim().to_string(),
job_title: contact.job_title.trim().to_string(),
notes: contact.notes.clone(),
address: contact.address.trim().to_string(),
addresses: clean_list(&contact.addresses, &contact.address),
website: contact.website.trim().to_string(),
birthday: contact.birthday.trim().to_string(),
}
}
fn all_emails(&self) -> Vec<String> {
let mut all = Vec::new();
if !self.email.is_empty() {
all.push(self.email.clone());
}
all.extend(self.emails.iter().cloned());
all
}
fn all_phones(&self) -> Vec<String> {
let mut all = Vec::new();
if !self.phone.is_empty() {
all.push(self.phone.clone());
}
all.extend(self.phones.iter().cloned());
all
}
fn all_addresses(&self) -> Vec<String> {
let mut all = Vec::new();
if !self.address.is_empty() {
all.push(self.address.clone());
}
all.extend(self.addresses.iter().cloned());
all
}
fn display_name(&self) -> String {
[&self.name, &self.email, &self.phone, &self.company]
.into_iter()
.find(|v| !v.trim().is_empty())
.cloned()
.unwrap_or_else(|| "Unnamed".into())
}
}
fn clean_list(values: &[String], primary: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for value in values {
let value = value.trim();
if value.is_empty()
|| value.eq_ignore_ascii_case(primary.trim())
|| out.iter().any(|v| v.eq_ignore_ascii_case(value))
{
continue;
}
out.push(value.to_string());
}
out
}
#[derive(Debug)]
pub struct ParsedCard {
pub fields: CardFields,
/// Embedded photo `(content type, bytes)`, if any.
pub photo: Option<(String, Vec<u8>)>,
}
fn card_lines(text: &str) -> Result<Vec<Line>, String> {
let mut depth = 0usize;
let mut cards = 0usize;
let mut lines = Vec::new();
for raw in content::unfold(text) {
if let Some((begin, name)) = content::component_marker(&raw)
&& name == "VCARD"
{
if begin {
depth += 1;
cards += 1;
if depth > 1 {
return Err("nested BEGIN:VCARD".into());
}
} else {
depth = depth.checked_sub(1).ok_or("unbalanced END:VCARD")?;
}
continue;
}
if depth == 1
&& let Some(line) = content::parse_line(&raw)
{
lines.push(line);
}
}
match (cards, depth) {
(1, 0) => Ok(lines),
(0, _) => Err("no vCard found".into()),
(_, 0) => Err("an address object resource must contain exactly one vCard".into()),
_ => Err("incomplete vCard".into()),
}
}
fn strip_scheme(value: &str, scheme: &str) -> String {
let value = value.trim();
match value.get(..scheme.len()) {
Some(prefix) if prefix.eq_ignore_ascii_case(scheme) => {
value[scheme.len()..].trim().to_string()
}
_ => value.to_string(),
}
}
fn is_pref(line: &Line) -> bool {
line.param_has("TYPE", "PREF") || line.param("PREF").is_some_and(|p| p.trim() == "1")
}
/// Ordered values: preferred first, then the rest in document order, deduplicated.
fn ordered(
lines: &[&Line],
value: impl Fn(&Line) -> String,
case_insensitive: bool,
) -> Vec<String> {
let mut sorted: Vec<&&Line> = lines.iter().filter(|l| is_pref(l)).collect();
sorted.extend(lines.iter().filter(|l| !is_pref(l)));
let mut out: Vec<String> = Vec::new();
for line in sorted {
let v = value(line);
if v.is_empty() {
continue;
}
let duplicate = out.iter().any(|existing| {
if case_insensitive {
existing.eq_ignore_ascii_case(&v)
} else {
*existing == v
}
});
if !duplicate {
out.push(v);
}
}
out
}
fn address_text(line: &Line) -> String {
split_structured(&line.value)
.into_iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(", ")
}
fn photo_type(data: &[u8]) -> Option<&'static str> {
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("image/jpeg")
} else if data.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("image/png")
} else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
Some("image/gif")
} else if data.len() > 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
Some("image/webp")
} else {
None
}
}
fn decode_photo(line: &Line) -> Option<(String, Vec<u8>)> {
let value = line.value.trim();
let encoded = if let Some(rest) = value.strip_prefix("data:") {
rest.split_once(',').map(|(_, data)| data)?
} else if line.param_has("ENCODING", "B") || line.param_has("ENCODING", "BASE64") {
value
} else {
return None;
};
let cleaned: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
let bytes = base64::engine::general_purpose::STANDARD
.decode(cleaned.as_bytes())
.ok()?;
if bytes.len() > MAX_PHOTO_BYTES {
return None;
}
let content_type = photo_type(&bytes)?;
Some((content_type.to_string(), bytes))
}
pub fn parse_card(text: &str) -> Result<ParsedCard, String> {
let lines = card_lines(text)?;
let named = |name: &str| -> Vec<&Line> { lines.iter().filter(|l| l.name == name).collect() };
let first = |name: &str| -> String {
lines
.iter()
.find(|l| l.name == name)
.map(|l| l.text().trim().to_string())
.unwrap_or_default()
};
let mut name = first("FN");
if name.is_empty()
&& let Some(n) = lines.iter().find(|l| l.name == "N")
{
let parts = split_structured(&n.value);
let given = parts.get(1).map(|s| s.trim()).unwrap_or("");
let family = parts.first().map(|s| s.trim()).unwrap_or("");
name = format!("{given} {family}").trim().to_string();
}
let emails = ordered(
&named("EMAIL"),
|l| strip_scheme(&l.text(), "mailto:"),
true,
);
let phones = ordered(&named("TEL"), |l| strip_scheme(&l.text(), "tel:"), false);
let addresses = ordered(&named("ADR"), address_text, false);
let company = lines
.iter()
.find(|l| l.name == "ORG")
.map(|l| {
split_structured(&l.value)
.into_iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let website = lines
.iter()
.find(|l| l.name == "URL")
.map(|l| l.value.trim().to_string())
.unwrap_or_default();
let split_first = |mut values: Vec<String>| -> (String, Vec<String>) {
if values.is_empty() {
(String::new(), Vec::new())
} else {
let primary = values.remove(0);
(primary, values)
}
};
let (email, emails) = split_first(emails);
let (phone, phones) = split_first(phones);
let (address, addresses) = split_first(addresses);
let photo = lines
.iter()
.filter(|l| l.name == "PHOTO")
.find_map(decode_photo);
Ok(ParsedCard {
fields: CardFields {
name,
email,
emails,
phone,
phones,
company,
job_title: first("TITLE"),
notes: lines
.iter()
.find(|l| l.name == "NOTE")
.map(Line::text)
.unwrap_or_default(),
address,
addresses,
website,
birthday: first("BDAY"),
},
photo,
})
}
fn name_lines(fields: &CardFields) -> Vec<String> {
let display = fields.display_name();
let mut words: Vec<&str> = fields.name.split_whitespace().collect();
let family = if words.len() > 1 {
words.pop().unwrap_or_default().to_string()
} else {
String::new()
};
let given = if fields.name.trim().is_empty() {
String::new()
} else {
words.join(" ")
};
vec![
format!("FN:{}", escape_text(&display)),
format!("N:{};{};;;", escape_text(&family), escape_text(&given)),
]
}
fn email_lines(fields: &CardFields) -> Vec<String> {
fields
.all_emails()
.iter()
.enumerate()
.map(|(i, e)| {
let pref = if i == 0 { ",PREF" } else { "" };
format!("EMAIL;TYPE=INTERNET{pref}:{}", escape_text(e))
})
.collect()
}
fn phone_lines(fields: &CardFields) -> Vec<String> {
fields
.all_phones()
.iter()
.enumerate()
.map(|(i, p)| {
let params = if i == 0 {
";TYPE=VOICE,PREF"
} else {
";TYPE=VOICE"
};
format!("TEL{params}:{}", escape_text(p))
})
.collect()
}
fn address_lines(fields: &CardFields) -> Vec<String> {
fields
.all_addresses()
.iter()
.enumerate()
.map(|(i, a)| {
let params = if i == 0 { ";TYPE=PREF" } else { "" };
format!("ADR{params}:;;{};;;;", escape_text(a))
})
.collect()
}
fn single(name: &str, value: &str, escape: bool) -> Vec<String> {
if value.trim().is_empty() {
Vec::new()
} else if escape {
vec![format!("{name}:{}", escape_text(value))]
} else {
vec![format!("{name}:{}", value.trim())]
}
}
/// Serialize a webmail-created contact (no stored CardDAV object) as vCard 3.0.
pub fn contact_to_vcard(contact: &Contact) -> String {
let fields = CardFields::from_contact(contact);
let mut lines = vec![
"BEGIN:VCARD".to_string(),
"VERSION:3.0".into(),
format!("PRODID:{PRODID}"),
format!("UID:{}", escape_text(&contact.id)),
format!("REV:{}", super::ical::stamp(&contact.updated_at)),
];
lines.extend(name_lines(&fields));
lines.extend(email_lines(&fields));
lines.extend(phone_lines(&fields));
lines.extend(address_lines(&fields));
lines.extend(single("ORG", &fields.company, true));
lines.extend(single("TITLE", &fields.job_title, true));
lines.extend(single("NOTE", &fields.notes, true));
lines.extend(single("URL", &fields.website, false));
lines.extend(single("BDAY", &fields.birthday, false));
lines.push("END:VCARD".into());
content::join(&lines)
}
/// Re-apply webmail edits to a stored vCard. `None` when nothing changed.
pub fn patch_card(raw: &str, contact: &Contact) -> Option<String> {
let wanted = CardFields::from_contact(contact);
let current = match parse_card(raw) {
Ok(parsed) => parsed.fields,
Err(_) => return Some(contact_to_vcard(contact)),
};
if current == wanted {
return None;
}
let lower = |v: Vec<String>| {
v.into_iter()
.map(|s| s.to_ascii_lowercase())
.collect::<Vec<_>>()
};
let mut drop: Vec<&str> = vec!["REV"];
let mut add: Vec<String> = Vec::new();
if current.name != wanted.name {
drop.extend(["FN", "N"]);
add.extend(name_lines(&wanted));
}
if lower(current.all_emails()) != lower(wanted.all_emails()) {
drop.push("EMAIL");
add.extend(email_lines(&wanted));
}
if current.all_phones() != wanted.all_phones() {
drop.push("TEL");
add.extend(phone_lines(&wanted));
}
if current.all_addresses() != wanted.all_addresses() {
drop.push("ADR");
add.extend(address_lines(&wanted));
}
for (name, old, new, escape) in [
("ORG", &current.company, &wanted.company, true),
("TITLE", &current.job_title, &wanted.job_title, true),
("NOTE", &current.notes, &wanted.notes, true),
("URL", &current.website, &wanted.website, false),
("BDAY", &current.birthday, &wanted.birthday, false),
] {
if old != new {
drop.push(name);
add.extend(single(name, new, escape));
}
}
add.push(format!("REV:{}", Utc::now().format("%Y%m%dT%H%M%SZ")));
let mut out = Vec::new();
let mut inside = false;
for line in content::unfold(raw) {
if let Some((begin, name)) = content::component_marker(&line)
&& name == "VCARD"
{
if !begin && inside {
out.append(&mut add);
}
inside = begin;
out.push(line);
continue;
}
if inside
&& let Some(parsed) = content::parse_line(&line)
&& drop.contains(&parsed.name.as_str())
{
continue;
}
out.push(line);
}
Some(content::join(&out))
}
/// Evaluate an RFC 6352 `<filter>` against a vCard.
pub fn matches_filter(card: &str, filter: Option<&Element>) -> bool {
let Some(filter) = filter else {
return true;
};
let prop_filters: Vec<&Element> = filter.children_named(NS_CARDDAV, "prop-filter").collect();
if prop_filters.is_empty() {
return true;
}
let Ok(lines) = card_lines(card) else {
return false;
};
let all_of = filter
.attr("test")
.is_some_and(|t| t.eq_ignore_ascii_case("allof"));
let mut results = prop_filters
.iter()
.map(|pf| prop_filter_matches(&lines, pf));
if all_of {
results.all(|r| r)
} else {
results.any(|r| r)
}
}
fn prop_filter_matches(lines: &[Line], filter: &Element) -> bool {
let name = filter.attr("name").unwrap_or("").to_ascii_uppercase();
let values: Vec<String> = lines
.iter()
.filter(|l| l.name == name)
.map(|l| l.text())
.collect();
if filter.child(NS_CARDDAV, "is-not-defined").is_some() {
return values.is_empty();
}
let matches: Vec<&Element> = filter.children_named(NS_CARDDAV, "text-match").collect();
if matches.is_empty() {
return !values.is_empty();
}
let all_of = filter
.attr("test")
.is_some_and(|t| t.eq_ignore_ascii_case("allof"));
let mut results = matches.iter().map(|tm| text_match(&values, tm));
if all_of {
results.all(|r| r)
} else {
results.any(|r| r)
}
}
fn text_match(values: &[String], element: &Element) -> bool {
let needle = element.text.trim();
let octet = element
.attr("collation")
.is_some_and(|c| c.eq_ignore_ascii_case("i;octet"));
let negate = element
.attr("negate-condition")
.is_some_and(|n| n.eq_ignore_ascii_case("yes"));
let match_type = element.attr("match-type").unwrap_or("contains");
let fold = |s: &str| {
if octet {
s.to_string()
} else {
s.to_lowercase()
}
};
let needle = fold(needle);
let hit = values.iter().any(|value| {
let value = fold(value);
match match_type {
"equals" => value == needle,
"starts-with" => value.starts_with(&needle),
"ends-with" => value.ends_with(&needle),
_ => value.contains(&needle),
}
});
hit != negate
}
#[cfg(test)]
mod tests {
use super::*;
fn contact() -> Contact {
Contact {
id: "c1".into(),
email: "jan@example.com".into(),
name: "Jan Kowalski".into(),
company: "ACME".into(),
notes: String::new(),
phone: "+48 600 000 000".into(),
job_title: String::new(),
address: String::new(),
website: String::new(),
birthday: String::new(),
emails: vec!["jan@private.example".into()],
phones: vec![],
addresses: vec![],
is_favorite: false,
last_contacted: None,
contact_count: 0,
source: "manual".into(),
created_at: "2026-11-01 10:00:00".into(),
updated_at: "2026-11-01 10:00:00".into(),
has_photo: false,
}
}
const PHONE_CARD: &str = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:phone-1\r\nFN:Anna Nowak\r\nN:Nowak;Anna;;;\r\n\
item1.EMAIL;TYPE=INTERNET:anna@work.example\r\nitem1.X-ABLabel:work\r\nEMAIL;TYPE=INTERNET,PREF:anna@example.com\r\n\
TEL;TYPE=CELL:+48 111\r\nTEL;TYPE=WORK:+48 222\r\nORG:Firma;Dział\r\nADR;TYPE=HOME:;;Polna 1;Warszawa;;00-001;PL\r\n\
X-SOCIALPROFILE:keep\r\nPHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\r\nEND:VCARD\r\n";
#[test]
fn parses_phone_card() {
let card = parse_card(PHONE_CARD).unwrap();
let f = card.fields;
assert_eq!(f.name, "Anna Nowak");
assert_eq!(f.email, "anna@example.com");
assert_eq!(f.emails, vec!["anna@work.example"]);
assert_eq!(f.phone, "+48 111");
assert_eq!(f.phones, vec!["+48 222"]);
assert_eq!(f.company, "Firma, Dział");
assert_eq!(f.address, "Polna 1, Warszawa, 00-001, PL");
let (ct, bytes) = card.photo.unwrap();
assert_eq!(ct, "image/jpeg");
assert_eq!(&bytes[..3], &[0xFF, 0xD8, 0xFF]);
}
#[test]
fn card_without_email_and_multiple_cards() {
let card = parse_card("BEGIN:VCARD\nVERSION:4.0\nFN:Plumber\nTEL:tel:+48 999\nEND:VCARD\n")
.unwrap();
assert_eq!(card.fields.email, "");
assert_eq!(card.fields.phone, "+48 999");
let two = "BEGIN:VCARD\nFN:a\nEND:VCARD\nBEGIN:VCARD\nFN:b\nEND:VCARD\n";
assert!(parse_card(two).is_err());
}
#[test]
fn generated_card_roundtrips() {
let c = contact();
let vcf = contact_to_vcard(&c);
assert!(vcf.contains("UID:c1\r\n"));
assert!(vcf.contains("N:Kowalski;Jan;;;\r\n"));
assert_eq!(
parse_card(&vcf).unwrap().fields,
CardFields::from_contact(&c)
);
}
#[test]
fn webmail_edit_patches_only_changed_properties() {
let parsed = parse_card(PHONE_CARD).unwrap().fields;
let mut c = contact();
c.name = parsed.name.clone();
c.email = parsed.email.clone();
c.emails = parsed.emails.clone();
c.phone = parsed.phone.clone();
c.phones = parsed.phones.clone();
c.company = parsed.company.clone();
c.address = parsed.address.clone();
assert!(patch_card(PHONE_CARD, &c).is_none());
c.job_title = "CTO".into();
let patched = patch_card(PHONE_CARD, &c).unwrap();
assert!(patched.contains("TITLE:CTO\r\n"));
assert!(patched.contains("item1.EMAIL;TYPE=INTERNET:anna@work.example\r\n"));
assert!(patched.contains("X-SOCIALPROFILE:keep\r\n"));
assert!(patched.contains("ADR;TYPE=HOME:;;Polna 1;Warszawa;;00-001;PL\r\n"));
assert!(patched.contains("PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\r\n"));
assert_eq!(parse_card(&patched).unwrap().fields.job_title, "CTO");
assert!(patched.trim_end().ends_with("END:VCARD"));
}
#[test]
fn addressbook_query_filter() {
let filter = super::super::xml::parse(
br#"<c:filter xmlns:c="urn:ietf:params:xml:ns:carddav" test="anyof">
<c:prop-filter name="FN"><c:text-match collation="i;unicode-casemap" match-type="contains">nowak</c:text-match></c:prop-filter>
<c:prop-filter name="EMAIL"><c:text-match match-type="contains">zzz</c:text-match></c:prop-filter>
</c:filter>"#,
)
.unwrap()
.unwrap();
assert!(matches_filter(PHONE_CARD, Some(&filter)));
let vcf = contact_to_vcard(&contact());
assert!(!matches_filter(&vcf, Some(&filter)));
}
}
+374
View File
@@ -0,0 +1,374 @@
//! Minimal namespace-aware XML handling for WebDAV request bodies and
//! `207 Multi-Status` responses.
use quick_xml::NsReader;
use quick_xml::events::Event;
use quick_xml::name::ResolveResult;
pub const NS_DAV: &str = "DAV:";
pub const NS_CALDAV: &str = "urn:ietf:params:xml:ns:caldav";
pub const NS_CARDDAV: &str = "urn:ietf:params:xml:ns:carddav";
pub const NS_CS: &str = "http://calendarserver.org/ns/";
pub const NS_APPLE: &str = "http://apple.com/ns/ical/";
/// Namespace declarations placed on every multistatus root element.
pub const ROOT_XMLNS: &str = r#"xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cr="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/" xmlns:ic="http://apple.com/ns/ical/""#;
/// A fully qualified XML name.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct QName {
pub ns: String,
pub name: String,
}
impl QName {
pub fn new(ns: &str, name: &str) -> Self {
Self {
ns: ns.to_string(),
name: name.to_string(),
}
}
pub fn is(&self, ns: &str, name: &str) -> bool {
self.ns == ns && self.name == name
}
/// Prefix used for this namespace in responses, if it is one of ours.
fn known_prefix(&self) -> Option<&'static str> {
match self.ns.as_str() {
NS_DAV => Some("d"),
NS_CALDAV => Some("c"),
NS_CARDDAV => Some("cr"),
NS_CS => Some("cs"),
NS_APPLE => Some("ic"),
_ => None,
}
}
/// An empty element for this name, e.g. for `404` propstat blocks.
pub fn empty_element(&self) -> String {
match self.known_prefix() {
Some(prefix) => format!("<{prefix}:{}/>", self.name),
None if self.ns.is_empty() => format!("<{} xmlns=\"\"/>", self.name),
None => format!("<x:{} xmlns:x=\"{}\"/>", self.name, escape(&self.ns)),
}
}
}
/// A parsed XML element.
#[derive(Debug, Clone, Default)]
pub struct Element {
pub ns: String,
pub name: String,
pub attrs: Vec<(String, String)>,
pub children: Vec<Element>,
pub text: String,
}
impl Element {
pub fn qname(&self) -> QName {
QName::new(&self.ns, &self.name)
}
pub fn is(&self, ns: &str, name: &str) -> bool {
self.ns == ns && self.name == name
}
pub fn child(&self, ns: &str, name: &str) -> Option<&Element> {
self.children.iter().find(|c| c.is(ns, name))
}
pub fn children_named<'a>(
&'a self,
ns: &'a str,
name: &'a str,
) -> impl Iterator<Item = &'a Element> + 'a {
self.children.iter().filter(move |c| c.is(ns, name))
}
pub fn attr(&self, name: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
}
/// Parse an XML document into an element tree. Returns `Ok(None)` for an empty body.
pub fn parse(body: &[u8]) -> Result<Option<Element>, String> {
let text = std::str::from_utf8(body).map_err(|_| "request body is not UTF-8".to_string())?;
if text.trim().is_empty() {
return Ok(None);
}
let mut reader = NsReader::from_str(text);
let mut stack: Vec<Element> = Vec::new();
let mut root: Option<Element> = None;
loop {
let (resolved, event) = reader
.read_resolved_event()
.map_err(|e| format!("malformed XML: {e}"))?;
let ns = match resolved {
ResolveResult::Bound(ns) => String::from_utf8_lossy(ns.as_ref()).into_owned(),
_ => String::new(),
};
match event {
Event::Start(start) | Event::Empty(start) if stack.len() > 64 => {
let _ = start;
return Err("XML nesting too deep".into());
}
Event::Start(start) => {
stack.push(element_from(&ns, &start)?);
}
Event::Empty(start) => {
let element = element_from(&ns, &start)?;
attach(&mut stack, &mut root, element);
}
Event::End(_) => {
let element = stack.pop().ok_or("unbalanced XML")?;
attach(&mut stack, &mut root, element);
}
Event::Text(text) => {
if let Some(top) = stack.last_mut() {
let value = text
.xml10_content()
.map_err(|e| format!("bad XML text: {e}"))?;
top.text.push_str(&value);
}
}
Event::CData(data) => {
if let Some(top) = stack.last_mut() {
let value = data.decode().map_err(|e| format!("bad CDATA: {e}"))?;
top.text.push_str(&value);
}
}
Event::GeneralRef(reference) => {
if let Some(top) = stack.last_mut() {
if let Some(ch) = reference
.resolve_char_ref()
.map_err(|e| format!("bad character reference: {e}"))?
{
top.text.push(ch);
} else {
let name = reference.decode().map_err(|e| format!("bad entity: {e}"))?;
let resolved = match name.as_ref() {
"amp" => '&',
"lt" => '<',
"gt" => '>',
"quot" => '"',
"apos" => '\'',
other => return Err(format!("unknown XML entity &{other};")),
};
top.text.push(resolved);
}
}
}
Event::Eof => break,
_ => {}
}
}
if !stack.is_empty() {
return Err("unexpected end of XML".into());
}
root.map(Some)
.ok_or_else(|| "XML document has no root".to_string())
}
fn element_from(ns: &str, start: &quick_xml::events::BytesStart<'_>) -> Result<Element, String> {
let name = String::from_utf8_lossy(start.local_name().as_ref()).into_owned();
let mut attrs = Vec::new();
for attr in start.attributes().with_checks(false) {
let attr = attr.map_err(|e| format!("bad XML attribute: {e}"))?;
let key = String::from_utf8_lossy(attr.key.local_name().as_ref()).into_owned();
if attr.key.as_ref().starts_with(b"xmlns") {
continue;
}
let value = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map_err(|e| format!("bad XML attribute value: {e}"))?
.into_owned();
attrs.push((key, value));
}
Ok(Element {
ns: ns.to_string(),
name,
attrs,
children: Vec::new(),
text: String::new(),
})
}
fn attach(stack: &mut [Element], root: &mut Option<Element>, element: Element) {
match stack.last_mut() {
Some(parent) => parent.children.push(element),
None => {
if root.is_none() {
*root = Some(element);
}
}
}
}
/// Escape text for use in XML content or attribute values.
pub fn escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
// Keep CR of CRLF line endings in calendar/address data: XML parsers
// normalize a literal CR away, a character reference survives.
'\r' => out.push_str("&#13;"),
// Characters that are not allowed in XML 1.0 documents.
c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {}
c => out.push(c),
}
}
out
}
/// Which properties a PROPFIND / REPORT asks for.
#[derive(Debug, Clone)]
pub enum PropRequest {
AllProp,
PropName,
Props(Vec<QName>),
}
/// Extract the requested properties from a `<prop>` / `<allprop>` / `<propname>` parent.
pub fn prop_request(parent: Option<&Element>) -> PropRequest {
let Some(parent) = parent else {
return PropRequest::AllProp;
};
if parent.child(NS_DAV, "propname").is_some() {
return PropRequest::PropName;
}
if let Some(prop) = parent.child(NS_DAV, "prop") {
return PropRequest::Props(prop.children.iter().map(Element::qname).collect());
}
PropRequest::AllProp
}
/// Incrementally built `207 Multi-Status` body.
pub struct MultiStatus {
body: String,
}
impl Default for MultiStatus {
fn default() -> Self {
Self::new()
}
}
impl MultiStatus {
pub fn new() -> Self {
let mut body = String::from(r#"<?xml version="1.0" encoding="utf-8"?>"#);
body.push_str(&format!("<d:multistatus {ROOT_XMLNS}>"));
Self { body }
}
/// Add a response with found (`200`) and missing (`404`) properties.
pub fn propstat(&mut self, href: &str, found: &[String], missing: &[QName]) {
self.body.push_str("<d:response><d:href>");
self.body.push_str(&escape(href));
self.body.push_str("</d:href>");
if !found.is_empty() || missing.is_empty() {
self.body.push_str("<d:propstat><d:prop>");
for prop in found {
self.body.push_str(prop);
}
self.body
.push_str("</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>");
}
if !missing.is_empty() {
self.body.push_str("<d:propstat><d:prop>");
for name in missing {
self.body.push_str(&name.empty_element());
}
self.body
.push_str("</d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>");
}
self.body.push_str("</d:response>");
}
/// Add a response whose properties all have the same status (e.g. PROPPATCH refusals).
pub fn propstat_status(&mut self, href: &str, names: &[QName], status: &str) {
self.body.push_str("<d:response><d:href>");
self.body.push_str(&escape(href));
self.body.push_str("</d:href><d:propstat><d:prop>");
for name in names {
self.body.push_str(&name.empty_element());
}
self.body.push_str("</d:prop><d:status>HTTP/1.1 ");
self.body.push_str(status);
self.body.push_str("</d:status></d:propstat></d:response>");
}
/// Add a response that only carries a status (e.g. `404` in multiget).
pub fn status(&mut self, href: &str, status: &str) {
self.body.push_str("<d:response><d:href>");
self.body.push_str(&escape(href));
self.body.push_str("</d:href><d:status>HTTP/1.1 ");
self.body.push_str(status);
self.body.push_str("</d:status></d:response>");
}
pub fn finish(mut self) -> String {
self.body.push_str("</d:multistatus>");
self.body
}
}
/// `<d:error>` body for failed preconditions.
pub fn error_body(inner: &str) -> String {
format!(r#"<?xml version="1.0" encoding="utf-8"?><d:error {ROOT_XMLNS}>{inner}</d:error>"#)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_propfind_with_namespaces_and_entities() {
let body = br#"<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:resourcetype/><d:displayname/><cs:getctag/><c:supported-calendar-component-set/></d:prop>
</d:propfind>"#;
let root = parse(body).unwrap().unwrap();
assert!(root.is(NS_DAV, "propfind"));
let PropRequest::Props(props) = prop_request(Some(&root)) else {
panic!("expected explicit props");
};
assert_eq!(props.len(), 4);
assert!(props[2].is(NS_CS, "getctag"));
assert!(props[3].is(NS_CALDAV, "supported-calendar-component-set"));
let href = parse(br#"<a:href xmlns:a="DAV:">/x/a&amp;b%20c.ics</a:href>"#)
.unwrap()
.unwrap();
assert_eq!(href.text, "/x/a&b%20c.ics");
// CRLF survives a round trip through escape() and an XML parser.
let data = format!("<a:x xmlns:a=\"DAV:\">{}</a:x>", escape("A\r\nB"));
assert_eq!(parse(data.as_bytes()).unwrap().unwrap().text, "A\r\nB");
}
#[test]
fn empty_body_means_allprop() {
assert!(parse(b" ").unwrap().is_none());
assert!(matches!(prop_request(None), PropRequest::AllProp));
}
#[test]
fn rejects_malformed_xml() {
assert!(parse(b"<a><b></a>").is_err());
}
#[test]
fn unknown_namespace_is_declared_inline() {
let name = QName::new("urn:example", "foo");
assert_eq!(name.empty_element(), r#"<x:foo xmlns:x="urn:example"/>"#);
}
}
+6 -1
View File
@@ -148,7 +148,9 @@ pub struct MeetingTemplate {
pub created_at: String,
}
fn row_to_event(row: &crate::db::pool::Row<'_>) -> Result<CalendarEvent, crate::db::pool::DbError> {
pub(crate) fn row_to_event(
row: &crate::db::pool::Row<'_>,
) -> Result<CalendarEvent, crate::db::pool::DbError> {
let all_day_int: i32 = row.get(6)?;
Ok(CalendarEvent {
id: row.get(0)?,
@@ -374,6 +376,9 @@ pub fn update_event(
return Ok(None);
}
// Keep the stored CalDAV object (if this event came from a DAV client) in sync.
crate::db::dav::refresh_event_object(conn, id);
get_event(conn, id)
}
+17 -1
View File
@@ -33,7 +33,9 @@ pub struct Contact {
pub has_photo: bool,
}
fn row_to_contact(row: &crate::db::pool::Row<'_>) -> Result<Contact, crate::db::pool::DbError> {
pub(crate) fn row_to_contact(
row: &crate::db::pool::Row<'_>,
) -> Result<Contact, crate::db::pool::DbError> {
Ok(Contact {
id: row.get(0)?,
email: row.get(1)?,
@@ -65,7 +67,16 @@ fn escape_like(term: &str) -> String {
.replace('_', "!_")
}
/// Insert or update a contact. When the contact came from a CardDAV client the
/// stored vCard is patched so DAV clients see the webmail edit.
pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> {
upsert_contact_row(conn, contact)?;
crate::db::dav::refresh_contact_object(conn, &contact.id);
Ok(())
}
/// Insert or update the contact columns only (no CardDAV object refresh).
pub(crate) fn upsert_contact_row(conn: &Connection, contact: &Contact) -> Result<(), String> {
conn.execute(
crate::dbq!(db_contacts::Q_001),
params![
@@ -280,6 +291,11 @@ pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), Str
#[allow(dead_code)]
pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> {
// Since V055 the e-mail index is no longer unique (CardDAV address books may
// hold several cards sharing one address), so the existence check is explicit.
if get_contact_by_email(conn, email)?.is_some() {
return Ok(());
}
let id = Uuid::new_v4().to_string();
conn.execute(crate::dbq!(db_contacts::Q_007), params![id, email, name])
.map_err(|e| format!("Failed to auto-add contact: {e}"))?;
+401
View File
@@ -0,0 +1,401 @@
//! Storage for the CalDAV / CardDAV server (`src/dav`).
//!
//! Every calendar event and contact is exposed as a DAV resource. Rows created
//! over DAV keep the uploaded object in `dav_data` and their resource name in
//! `dav_name`; rows created in the webmail have neither and are served under
//! `<id>.ics` / `<id>.vcf` with a generated body.
use crate::db::calendar::{CalendarEvent, CreateEvent};
use crate::db::contacts::Contact;
use crate::db::pool::{Connection, DbError, params};
use crate::queries::sqlite::queries::db_calendar::EVENT_SELECT_COLS;
use crate::queries::sqlite::queries::db_contacts::CONTACT_SELECT_COLS;
/// An event together with its DAV representation.
#[derive(Debug, Clone)]
pub struct DavEvent {
pub event: CalendarEvent,
pub dav_name: Option<String>,
pub dav_data: Option<String>,
}
impl DavEvent {
pub fn resource_name(&self) -> String {
self.dav_name
.clone()
.unwrap_or_else(|| format!("{}.ics", self.event.id))
}
pub fn body(&self) -> String {
self.dav_data
.clone()
.unwrap_or_else(|| crate::dav::ical::event_to_ical(&self.event))
}
}
/// A contact together with its DAV representation.
#[derive(Debug, Clone)]
pub struct DavContact {
pub contact: Contact,
pub dav_name: Option<String>,
pub dav_data: Option<String>,
}
impl DavContact {
pub fn resource_name(&self) -> String {
self.dav_name
.clone()
.unwrap_or_else(|| format!("{}.vcf", self.contact.id))
}
pub fn body(&self) -> String {
self.dav_data
.clone()
.unwrap_or_else(|| crate::dav::vcard::contact_to_vcard(&self.contact))
}
}
fn row_to_dav_event(row: &crate::db::pool::Row<'_>) -> Result<DavEvent, DbError> {
Ok(DavEvent {
event: crate::db::calendar::row_to_event(row)?,
dav_name: row.get(21)?,
dav_data: row.get(22)?,
})
}
fn row_to_dav_contact(row: &crate::db::pool::Row<'_>) -> Result<DavContact, DbError> {
Ok(DavContact {
contact: crate::db::contacts::row_to_contact(row)?,
dav_name: row.get(20)?,
dav_data: row.get(21)?,
})
}
fn event_select(filter: &str) -> String {
format!("SELECT {EVENT_SELECT_COLS}, dav_name, dav_data FROM calendar_events {filter}")
}
fn contact_select(filter: &str) -> String {
format!("SELECT {CONTACT_SELECT_COLS}, dav_name, dav_data FROM contacts {filter}")
}
fn optional<T>(result: Result<T, DbError>, what: &str) -> Result<Option<T>, String> {
match result {
Ok(value) => Ok(Some(value)),
Err(DbError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to load {what}: {e}")),
}
}
/// The id a generated resource name refers to (`<id>.ics` -> `<id>`).
fn generated_id<'a>(name: &'a str, extension: &str) -> &'a str {
name.strip_suffix(extension).unwrap_or(name)
}
// ----------------------------------------------------------------- events
pub fn list_events(conn: &Connection) -> Result<Vec<DavEvent>, String> {
let sql = event_select("ORDER BY start_time ASC, id ASC");
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare DAV event list: {e}"))?;
let rows = stmt
.query_map([], row_to_dav_event)
.map_err(|e| format!("Failed to list DAV events: {e}"))?;
rows.map(|r| r.map_err(|e| format!("Failed to read DAV event: {e}")))
.collect()
}
pub fn find_event_by_name(conn: &Connection, name: &str) -> Result<Option<DavEvent>, String> {
let sql = event_select("WHERE dav_name = ?1 OR (dav_name IS NULL AND id = ?2)");
optional(
conn.query_row(
&sql,
params![name, generated_id(name, ".ics")],
row_to_dav_event,
),
"DAV event",
)
}
pub fn find_event_by_uid(conn: &Connection, uid: &str) -> Result<Option<DavEvent>, String> {
let sql = event_select("WHERE source_uid = ?1 OR (source_uid IS NULL AND id = ?1)");
optional(
conn.query_row(&sql, params![uid], row_to_dav_event),
"DAV event by UID",
)
}
pub fn get_event(conn: &Connection, id: &str) -> Result<Option<DavEvent>, String> {
let sql = event_select("WHERE id = ?1");
optional(
conn.query_row(&sql, params![id], row_to_dav_event),
"DAV event",
)
}
/// Insert or replace an event received over CalDAV. Returns the row id.
pub fn save_event(
conn: &Connection,
existing_id: Option<&str>,
data: &CreateEvent,
dav_name: &str,
dav_data: &str,
) -> Result<String, String> {
let id = match existing_id {
Some(id) => {
conn.execute(
"UPDATE calendar_events SET title = ?1, description = ?2, location = ?3, \
start_time = ?4, end_time = ?5, all_day = ?6, recurrence_rule = ?7, attendees = ?8, \
organizer = ?9, status = ?10, source_uid = ?11, meeting_url = ?12, color = ?13, \
reminder_minutes = ?14, reminder_extra_minutes = ?15, updated_at = datetime('now') \
WHERE id = ?16",
params![
data.title,
data.description,
data.location,
data.start_time,
data.end_time,
data.all_day as i32,
data.recurrence_rule,
data.attendees,
data.organizer,
data.status,
data.source_uid,
data.meeting_url,
data.color,
data.reminder_minutes,
data.reminder_extra_minutes,
id,
],
)
.map_err(|e| format!("Failed to update DAV event: {e}"))?;
id.to_string()
}
None => crate::db::calendar::create_event(conn, data)?.id,
};
set_event_dav(conn, &id, Some(dav_name), dav_data)?;
Ok(id)
}
fn set_event_dav(
conn: &Connection,
id: &str,
dav_name: Option<&str>,
dav_data: &str,
) -> Result<(), String> {
match dav_name {
Some(name) => conn.execute(
"UPDATE calendar_events SET dav_name = ?1, dav_data = ?2 WHERE id = ?3",
params![name, dav_data, id],
),
None => conn.execute(
"UPDATE calendar_events SET dav_data = ?1 WHERE id = ?2",
params![dav_data, id],
),
}
.map_err(|e| format!("Failed to store CalDAV object: {e}"))?;
Ok(())
}
/// After a webmail edit, rewrite the stored CalDAV object of `id` (if any) so
/// DAV clients see the change. Best effort: failures are logged, not returned.
pub fn refresh_event_object(conn: &Connection, id: &str) {
let result = (|| -> Result<(), String> {
let Some(row) = get_event(conn, id)? else {
return Ok(());
};
let Some(raw) = row.dav_data.as_deref() else {
return Ok(());
};
if let Some(patched) = crate::dav::ical::patch_object(raw, &row.event) {
set_event_dav(conn, id, None, &patched)?;
}
Ok(())
})();
if let Err(error) = result {
tracing::warn!(%error, "failed to refresh CalDAV object after webmail edit");
}
}
// --------------------------------------------------------------- contacts
pub fn list_contacts(conn: &Connection) -> Result<Vec<DavContact>, String> {
let sql = contact_select("ORDER BY id ASC");
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare DAV contact list: {e}"))?;
let rows = stmt
.query_map([], row_to_dav_contact)
.map_err(|e| format!("Failed to list DAV contacts: {e}"))?;
rows.map(|r| r.map_err(|e| format!("Failed to read DAV contact: {e}")))
.collect()
}
pub fn find_contact_by_name(conn: &Connection, name: &str) -> Result<Option<DavContact>, String> {
let sql = contact_select("WHERE dav_name = ?1 OR (dav_name IS NULL AND id = ?2)");
optional(
conn.query_row(
&sql,
params![name, generated_id(name, ".vcf")],
row_to_dav_contact,
),
"DAV contact",
)
}
pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<DavContact>, String> {
let sql = contact_select("WHERE id = ?1");
optional(
conn.query_row(&sql, params![id], row_to_dav_contact),
"DAV contact",
)
}
/// Insert or replace a contact received over CardDAV.
pub fn save_contact(
conn: &Connection,
contact: &Contact,
dav_name: &str,
dav_data: &str,
) -> Result<(), String> {
crate::db::contacts::upsert_contact_row(conn, contact)?;
set_contact_dav(conn, &contact.id, Some(dav_name), dav_data)
}
fn set_contact_dav(
conn: &Connection,
id: &str,
dav_name: Option<&str>,
dav_data: &str,
) -> Result<(), String> {
match dav_name {
Some(name) => conn.execute(
"UPDATE contacts SET dav_name = ?1, dav_data = ?2 WHERE id = ?3",
params![name, dav_data, id],
),
None => conn.execute(
"UPDATE contacts SET dav_data = ?1 WHERE id = ?2",
params![dav_data, id],
),
}
.map_err(|e| format!("Failed to store CardDAV object: {e}"))?;
Ok(())
}
/// Counterpart of [`refresh_event_object`] for contacts.
pub fn refresh_contact_object(conn: &Connection, id: &str) {
let result = (|| -> Result<(), String> {
let Some(row) = get_contact(conn, id)? else {
return Ok(());
};
let Some(raw) = row.dav_data.as_deref() else {
return Ok(());
};
if let Some(patched) = crate::dav::vcard::patch_card(raw, &row.contact) {
set_contact_dav(conn, id, None, &patched)?;
}
Ok(())
})();
if let Err(error) = result {
tracing::warn!(%error, "failed to refresh CardDAV object after webmail edit");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::pool::open_test_db;
fn sample() -> CreateEvent {
serde_json::from_value(serde_json::json!({
"title": "Webmail event",
"start_time": "2026-11-10T09:00:00.000Z",
"end_time": "2026-11-10T10:00:00.000Z"
}))
.unwrap()
}
#[test]
fn webmail_events_get_generated_names() {
let conn = open_test_db();
let event = crate::db::calendar::create_event(&conn, &sample()).unwrap();
let found = find_event_by_name(&conn, &format!("{}.ics", event.id))
.unwrap()
.unwrap();
assert_eq!(found.event.id, event.id);
assert!(found.dav_data.is_none());
assert!(found.body().contains("SUMMARY:Webmail event"));
assert!(find_event_by_uid(&conn, &event.id).unwrap().is_some());
}
#[test]
fn dav_events_store_object_and_follow_webmail_edits() {
let conn = open_test_db();
let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u-1\r\nDTSTART:20261110T090000Z\r\nDTEND:20261110T100000Z\r\nSUMMARY:From phone\r\nX-KEEP:1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let parsed = crate::dav::ical::parse_object(raw).unwrap();
let data = parsed.fields.to_create_event(&parsed.uid, "caldav");
let id = save_event(&conn, None, &data, "phone-1.ics", raw).unwrap();
let stored = find_event_by_name(&conn, "phone-1.ics").unwrap().unwrap();
assert_eq!(stored.event.id, id);
assert_eq!(stored.body(), raw);
assert_eq!(
find_event_by_uid(&conn, "u-1").unwrap().unwrap().event.id,
id
);
let update: crate::db::calendar::UpdateEvent =
serde_json::from_value(serde_json::json!({"title": "Edited in webmail"})).unwrap();
crate::db::calendar::update_event(&conn, &id, &update).unwrap();
let body = find_event_by_name(&conn, "phone-1.ics")
.unwrap()
.unwrap()
.body();
assert!(body.contains("SUMMARY:Edited in webmail\r\n"));
assert!(body.contains("X-KEEP:1\r\n"));
}
#[test]
fn dav_contacts_allow_shared_addresses_and_follow_webmail_edits() {
let conn = open_test_db();
let raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:card-1\r\nFN:Anna\r\nEMAIL:anna@example.com\r\nX-KEEP:1\r\nEND:VCARD\r\n";
let parsed = crate::dav::vcard::parse_card(raw).unwrap();
let mut contact = crate::db::contacts::Contact {
id: "id-1".into(),
email: parsed.fields.email.clone(),
name: parsed.fields.name.clone(),
company: String::new(),
notes: String::new(),
phone: String::new(),
job_title: String::new(),
address: String::new(),
website: String::new(),
birthday: String::new(),
emails: vec![],
phones: vec![],
addresses: vec![],
is_favorite: false,
last_contacted: None,
contact_count: 0,
source: "carddav".into(),
created_at: "2026-11-01 00:00:00".into(),
updated_at: "2026-11-01 00:00:00".into(),
has_photo: false,
};
save_contact(&conn, &contact, "card-1.vcf", raw).unwrap();
// A second card with the same address must not collide (V055 index).
contact.id = "id-2".into();
save_contact(&conn, &contact, "card-2.vcf", raw).unwrap();
assert_eq!(list_contacts(&conn).unwrap().len(), 2);
contact.id = "id-1".into();
contact.company = "ACME".into();
crate::db::contacts::upsert_contact(&conn, &contact).unwrap();
let body = find_contact_by_name(&conn, "card-1.vcf")
.unwrap()
.unwrap()
.body();
assert!(body.contains("ORG:ACME\r\n"));
assert!(body.contains("X-KEEP:1\r\n"));
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod blob_delete_outbox;
pub mod calendar;
pub mod contact_groups;
pub mod contacts;
pub mod dav;
pub mod display_preferences;
pub mod drafts;
pub mod folder_preferences;
+29
View File
@@ -32,6 +32,10 @@ const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
"../../migrations/sqlite/V054__theme_schedule_account_color_and_reminders.sql"
),
),
(
55,
include_str!("../../migrations/sqlite/V055__caldav_carddav.sql"),
),
];
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
(
@@ -42,6 +46,10 @@ const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
54,
include_str!("../../migrations/mysql/V054__theme_schedule_account_color_and_reminders.sql"),
),
(
55,
include_str!("../../migrations/mysql/V055__caldav_carddav.sql"),
),
];
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
(
@@ -52,6 +60,10 @@ const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
54,
include_str!("../../migrations/pgsql/V054__theme_schedule_account_color_and_reminders.sql"),
),
(
55,
include_str!("../../migrations/pgsql/V055__caldav_carddav.sql"),
),
];
const TENANT_TABLES: &[&str] = &[
@@ -2654,6 +2666,8 @@ mod tests {
CREATE TABLE u_test__display_preferences (id INTEGER PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system', interface_skin TEXT NOT NULL DEFAULT 'rustbox');\n\
CREATE TABLE u_test__calendar_events (id TEXT PRIMARY KEY, reminder_minutes INTEGER);\n\
CREATE TABLE u_test__calendar_settings (id INTEGER PRIMARY KEY);\n\
CREATE TABLE u_test__contacts (id TEXT PRIMARY KEY, email TEXT NOT NULL);\n\
CREATE UNIQUE INDEX u_test__idx_contacts_email ON u_test__contacts(email);\n\
INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES('test', 'u_test__', 52);\n\
INSERT INTO u_test__display_preferences(id, interface_skin) VALUES(1, 'nord');\n\
INSERT INTO u_test__calendar_events(id, reminder_minutes) VALUES('e1', 15);\n\
@@ -2701,6 +2715,21 @@ mod tests {
.unwrap();
assert_eq!(reminder, 15);
assert_eq!(extra, None);
let (dav_name, dav_data): (Option<String>, Option<String>) = conn
.query_row(
"SELECT dav_name, dav_data FROM u_test__calendar_events WHERE id='e1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!((dav_name, dav_data), (None, None));
// V055: CardDAV address books may hold several cards sharing one address.
conn.execute_batch(
"INSERT INTO u_test__contacts(id, email) VALUES('c1', 'a@example.com');\n\
INSERT INTO u_test__contacts(id, email) VALUES('c2', 'a@example.com');\n\
UPDATE u_test__contacts SET dav_name='c2.vcf', dav_data='BEGIN:VCARD' WHERE id='c2';",
)
.unwrap();
let (default_reminder, default_extra): (Option<i64>, Option<i64>) = conn
.query_row(
"SELECT default_reminder_minutes, default_extra_reminder_minutes FROM u_test__calendar_settings WHERE id=1",
+1
View File
@@ -1,6 +1,7 @@
mod auth;
mod calendar;
mod config;
mod dav;
mod db;
mod email_theme;
mod error;
+1 -1
View File
@@ -154,7 +154,7 @@ fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, Stri
/// Client IP used for login throttling; honours `trusted_proxies` the same way
/// as the per-IP token bucket and the security telemetry.
fn login_client_ip(
pub(crate) fn login_client_ip(
config: &AppConfig,
extensions: &axum::http::Extensions,
headers: &axum::http::HeaderMap,
+13
View File
@@ -657,6 +657,12 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
.nest("/api", api_router)
.route("/", get(crate::ui::serve_ui))
.fallback(crate::ui::serve_ui);
// Optional CalDAV / CardDAV server under <base_path>/dav.
let inner = if config.dav.enabled {
inner.merge(crate::dav::routes(&config))
} else {
inner
};
let router = match config.normalized_base_path() {
"/" => inner,
@@ -668,6 +674,13 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
}
};
// RFC 6764 discovery always lives at the host root, also behind a base path.
let router = if config.dav.enabled {
router.merge(crate::dav::well_known_routes())
} else {
router
};
let router = if config.diagnostics_endpoint.enabled {
let system_diagnostics_dependencies =
Arc::new(system_diagnostics::SystemDiagnosticsDependencies::new(