diff --git a/CLAUDE.md b/CLAUDE.md index 987a1a8..d8b6e58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,9 @@ | CLI | `clap` v4 (derive API) | Subcommand tree pattern | | Async runtime | `tokio` (full features) | Used throughout | | Database | SQLite via `sqlx` 0.7 | WAL mode, foreign keys enabled, migrations inlined | -| HTTP client | `reqwest` (rustls, no openssl) | CalDAV PROPFIND/REPORT requests | +| HTTP client | `reqwest` (rustls, no openssl) | CalDAV PROPFIND/REPORT and EWS SOAP requests | +| Calendar providers | trait `CalendarProvider` (`src/providers/`) | Pluggable back-ends: CalDAV, EWS (Exchange 2019). Sync/source code dispatches via the trait. | +| Async traits | `async-trait` 0.1 | Object-safe `dyn CalendarProvider`. | | XML parsing | `quick-xml` 0.31 | CalDAV responses are XML over WebDAV | | iCal | `icalendar` crate | Parsing/generating VEVENT data | | Time | `chrono` + `chrono-tz` | Timezone handling is a known complexity area | @@ -90,7 +92,8 @@ calrs/ │ ├── 041_last_full_sync.sql ← last_full_sync timestamp on caldav_sources │ ├── 042_event_transp.sql ← TRANSP column on events (skip TRANSPARENT) │ ├── 043_event_type_watchers.sql ← event_type_watchers junction (team watches event type) -│ └── 044_booking_claim.sql ← claimed_by_user_id/claimed_at on bookings + booking_claim_tokens +│ ├── 044_booking_claim.sql ← claimed_by_user_id/claimed_at on bookings + booking_claim_tokens +│ └── 055_provider_type.sql ← provider_type on caldav_sources (caldav/ews) for the calendar-provider abstraction ├── templates/ │ ├── base.html ← base layout + CSS (light/dark mode) │ ├── dashboard_base.html ← sidebar layout (extends base.html, all dashboard pages extend this) diff --git a/Cargo.lock b/Cargo.lock index 836cb50..4eddb4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,6 +362,7 @@ dependencies = [ "aes-gcm", "anyhow", "argon2", + "async-trait", "axum", "axum-extra", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 48af99a..77fa474 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ clap = { version = "4", features = ["derive", "env"] } # Async runtime tokio = { version = "1", features = ["full"] } +async-trait = "0.1" # Database sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono", "uuid"] } diff --git a/migrations/055_provider_type.sql b/migrations/055_provider_type.sql new file mode 100644 index 0000000..892a72d --- /dev/null +++ b/migrations/055_provider_type.sql @@ -0,0 +1,15 @@ +-- Add provider_type column to caldav_sources so a single sources table can +-- describe multiple back-end protocols (CalDAV, EWS, ...). Existing rows keep +-- the historical default of 'caldav'. +-- +-- Schema reuse note: the `calendars` table keeps its CalDAV-era column names +-- when populated by an EWS source. Specifically: +-- * `calendars.href` holds the EWS folder ItemId (opaque base64-ish blob). +-- * `calendars.ctag` holds the EWS ChangeKey, if any. +-- The semantics are identical (opaque change marker / opaque resource id) +-- and the rest of the codebase treats them that way via the provider trait +-- (`crate::providers::RemoteCalendar`). Renaming the columns would force a +-- table-rebuild migration on existing CalDAV deployments for no behavioural +-- gain, so we accept the misleading names and surface them here. +ALTER TABLE caldav_sources ADD COLUMN provider_type TEXT NOT NULL DEFAULT 'caldav'; +CREATE INDEX IF NOT EXISTS idx_caldav_sources_provider_type ON caldav_sources(provider_type); diff --git a/src/caldav/mod.rs b/src/caldav/mod.rs index 4020278..7e2b552 100644 --- a/src/caldav/mod.rs +++ b/src/caldav/mod.rs @@ -45,7 +45,7 @@ fn is_private_ip(ip: &IpAddr) -> bool { /// Parse the `CALRS_ALLOW_PRIVATE_HOSTS` env var into a list of hostnames that /// are permitted to resolve to private/reserved IPs. Comma-separated, /// whitespace-trimmed, case-insensitive. Empty entries are ignored. -fn private_host_allowlist() -> Vec { +pub fn private_host_allowlist() -> Vec { std::env::var("CALRS_ALLOW_PRIVATE_HOSTS") .unwrap_or_default() .split(',') diff --git a/src/commands/source.rs b/src/commands/source.rs index f9ce2cf..9587862 100644 --- a/src/commands/source.rs +++ b/src/commands/source.rs @@ -5,6 +5,8 @@ use sqlx::SqlitePool; use tabled::{Table, Tabled}; use uuid::Uuid; +use crate::providers::{build_provider, factory}; + use std::io::{self, Write}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -12,9 +14,10 @@ use crate::utils::prompt; #[derive(Debug, Subcommand)] pub enum SourceCommands { - /// Connect a CalDAV calendar + /// Connect a calendar source (CalDAV or Exchange/EWS) Add { - /// CalDAV server URL + /// Source URL. CalDAV: discovery root. EWS: `Exchange.asmx` endpoint + /// (auto-discovered when omitted with `--provider ews`). #[arg(long)] url: Option, /// Username @@ -23,6 +26,12 @@ pub enum SourceCommands { /// Display name for this source #[arg(long)] name: Option, + /// Provider type: `caldav` (default) or `ews`. + #[arg(long, default_value = "caldav")] + provider: String, + /// For EWS: email used for Autodiscover when --url is not supplied. + #[arg(long)] + email: Option, /// Skip the connection test #[arg(long)] no_test: bool, @@ -34,7 +43,7 @@ pub enum SourceCommands { /// Source ID id: String, }, - /// Test a CalDAV connection + /// Test a connection Test { /// Source ID id: String, @@ -70,6 +79,8 @@ struct SourceRow { id: String, #[tabled(rename = "Name")] name: String, + #[tabled(rename = "Type")] + provider: String, #[tabled(rename = "URL")] url: String, #[tabled(rename = "Username")] @@ -84,31 +95,78 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu url, username, name, + provider, + email, no_test, } => { + let provider = provider.trim().to_ascii_lowercase(); + if provider != factory::kinds::CALDAV && provider != factory::kinds::EWS { + bail!("unknown provider '{}'. Use 'caldav' or 'ews'.", provider); + } + let account: (String,) = sqlx::query_as("SELECT id FROM accounts LIMIT 1") .fetch_optional(pool) .await? .ok_or_else(|| anyhow::anyhow!("No account found. Run `calrs init` first."))?; - let url = url.unwrap_or_else(|| prompt("CalDAV URL")); let username = username.unwrap_or_else(|| prompt("Username")); let name = name.unwrap_or_else(|| prompt("Display name")); let password = rpassword::prompt_password("Password: ").unwrap_or_default(); - // Test connection + // Resolve URL: explicit flag wins; otherwise EWS gets a chance to + // autodiscover from the email; CalDAV always asks the user. + let url = match url { + Some(u) => u, + None => match provider.as_str() { + factory::kinds::EWS => { + let email_for_disco = email + .clone() + .unwrap_or_else(|| prompt("Email (for Autodiscover)")); + print!( + "{} Discovering EWS endpoint via Autodiscover… ", + "…".dimmed() + ); + io::stdout().flush().ok(); + match crate::ews::autodiscover::discover_ews_url( + &email_for_disco, + &password, + ) + .await + { + Ok(u) => { + println!("{}", u.green()); + u + } + Err(e) => { + println!("{}", "failed".red()); + println!( + " {} Autodiscover failed: {}. Falling back to manual entry.", + "!".yellow(), + e + ); + prompt("EWS Exchange.asmx URL") + } + } + } + _ => prompt("CalDAV URL"), + }, + }; + + // Validate URL (HTTPS, no SSRF target). + factory::validate_url(&provider, &url)?; + + // Test connection unless skipped if !no_test { print!("{} Testing connection… ", "…".dimmed()); io::stdout().flush().unwrap(); - let client = crate::caldav::CaldavClient::new(&url, &username, &password); + let client = build_provider(&provider, &url, &username, &password)?; match client.check_connection().await { - Ok(true) => println!("{}", "CalDAV supported".green()), + Ok(true) => println!("{}", "OK".green()), Ok(false) => { println!( "{}", - "No CalDAV support detected (missing calendar-access in DAV header)" - .yellow() + "Connected, but provider features not explicitly advertised".yellow() ); println!("Continuing anyway…"); } @@ -123,7 +181,7 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu let password_enc = crate::crypto::encrypt_password(key, &password)?; sqlx::query( - "INSERT INTO caldav_sources (id, account_id, name, url, username, password_enc) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO caldav_sources (id, account_id, name, url, username, password_enc, provider_type) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&account.0) @@ -131,17 +189,26 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu .bind(&url) .bind(&username) .bind(&password_enc) + .bind(&provider) .execute(pool) .await?; - println!("{} Source '{}' added (id: {})", "✓".green(), name, &id[..8]); + println!( + "{} Source '{}' ({}) added (id: {})", + "✓".green(), + name, + factory::label(&provider), + &id[..8] + ); } SourceCommands::List => { - let sources: Vec<(String, String, String, String, Option)> = sqlx::query_as( - "SELECT id, name, url, username, last_synced FROM caldav_sources ORDER BY created_at", - ) - .fetch_all(pool) - .await?; + let sources: Vec<(String, String, String, String, Option, String)> = + sqlx::query_as( + "SELECT id, name, url, username, last_synced, provider_type + FROM caldav_sources ORDER BY created_at", + ) + .fetch_all(pool) + .await?; if sources.is_empty() { println!("No sources configured. Add one with `calrs source add`."); @@ -150,13 +217,16 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu let rows: Vec = sources .into_iter() - .map(|(id, name, url, username, last_synced)| SourceRow { - id: id[..8].to_string(), - name, - url, - username, - last_synced: last_synced.unwrap_or_else(|| "never".to_string()), - }) + .map( + |(id, name, url, username, last_synced, provider_type)| SourceRow { + id: id[..8].to_string(), + name, + provider: factory::label(&provider_type).to_string(), + url, + username, + last_synced: last_synced.unwrap_or_else(|| "never".to_string()), + }, + ) .collect(); println!("{}", Table::new(rows)); @@ -252,8 +322,20 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu } } SourceCommands::Test { id } => { - let source: Option<(String, String, String, String, Option, String, Option, Option)> = sqlx::query_as( - "SELECT id, url, username, name, password_enc, auth_type, access_token_enc, token_expires_at FROM caldav_sources WHERE id LIKE ? || '%'", + let source: Option<( + String, + String, + String, + String, + Option, + String, + Option, + Option, + String, + )> = sqlx::query_as( + "SELECT id, url, username, name, password_enc, auth_type, \ + access_token_enc, token_expires_at, provider_type \ + FROM caldav_sources WHERE id LIKE ? || '%'", ) .bind(&id) .fetch_optional(pool) @@ -269,23 +351,44 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu auth_type, access_token_enc, token_expires_at, + provider_type, )) => { - println!("Testing source '{}'…", name); - let client = crate::oauth2_caldav::build_client_for_source( - pool, - key, - &source_id, - &url, - &auth_type, - &username, - password_enc.as_deref(), - access_token_enc.as_deref(), - token_expires_at.as_deref(), - ) - .await?; + println!( + "Testing source '{}' ({})…", + name, + factory::label(&provider_type) + ); + + // OAuth2 sources are CalDAV-only (Google). Basic-auth + // sources may be CalDAV or EWS; let the provider factory + // pick the right back-end. + let client: Box = + if auth_type == "oauth2" { + let caldav = crate::oauth2_caldav::build_client_for_source( + pool, + key, + &source_id, + &url, + &auth_type, + &username, + password_enc.as_deref(), + access_token_enc.as_deref(), + token_expires_at.as_deref(), + ) + .await?; + Box::new(crate::providers::caldav::CaldavProvider::from_client( + caldav, + )) + } else { + let enc = password_enc.as_deref().ok_or_else(|| { + anyhow::anyhow!("Basic auth source missing password") + })?; + let password = crate::crypto::decrypt_password(key, enc)?; + build_provider(&provider_type, &url, &username, &password)? + }; match client.check_connection().await { - Ok(true) => println!("{} Connection OK — CalDAV supported", "✓".green()), - Ok(false) => println!("{} Connected but CalDAV not detected", "⚠".yellow()), + Ok(true) => println!("{} Connection OK", "✓".green()), + Ok(false) => println!("{} Connected, partial detection", "⚠".yellow()), Err(e) => println!("{} Connection failed: {}", "✗".red(), e), } } diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 32ba4e1..9320421 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -8,6 +8,7 @@ use tokio::sync::Mutex; use uuid::Uuid; use crate::caldav::{CaldavClient, RawEvent}; +use crate::providers::{factory::kinds, RawEvent as ProviderRawEvent}; use crate::utils::{extract_vevent_field, extract_vevent_tzid, split_vevents}; /// Default staleness threshold: 5 minutes @@ -39,8 +40,8 @@ pub(crate) async fn source_lock(source_id: &str) -> Arc> { } pub async fn run(pool: &SqlitePool, key: &[u8; 32], full: bool) -> Result<()> { - let sources: Vec<(String, String, String, String, Option, String, Option, Option)> = sqlx::query_as( - "SELECT id, name, url, username, password_enc, auth_type, access_token_enc, token_expires_at FROM caldav_sources WHERE enabled = 1", + let sources: Vec<(String, String, String, String, Option, String, Option, Option, String)> = sqlx::query_as( + "SELECT id, name, url, username, password_enc, auth_type, access_token_enc, token_expires_at, provider_type FROM caldav_sources WHERE enabled = 1", ) .fetch_all(pool) .await?; @@ -59,10 +60,46 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], full: bool) -> Result<()> { auth_type, access_token_enc, token_expires_at, + provider_type, ) in &sources { println!("{} Syncing '{}'…", "…".dimmed(), name); + if full { + // Clear sync tokens to force a full fetch + let _ = sqlx::query( + "UPDATE calendars SET sync_token = NULL, ctag = NULL WHERE source_id = ?", + ) + .bind(source_id) + .execute(pool) + .await; + } + + // EWS sources go through the provider trait (no OAuth2, no CalDAV-only + // sync-collection); CalDAV sources keep the existing flow. + if provider_type == kinds::EWS { + let password = + match crate::crypto::decrypt_password(key, password_enc.as_deref().unwrap_or("")) { + Ok(p) => p, + Err(e) => { + println!(" {} Decrypt failed: {}", "✗".red(), e); + continue; + } + }; + let provider = + match crate::providers::build_provider(provider_type, url, username, &password) { + Ok(p) => p, + Err(e) => { + println!(" {} Provider build failed: {}", "✗".red(), e); + continue; + } + }; + if let Err(e) = sync_ews_source(pool, key, provider.as_ref(), source_id).await { + println!(" {} Sync failed: {}", "✗".red(), e); + } + continue; + } + let client = crate::oauth2_caldav::build_client_for_source( pool, key, @@ -76,16 +113,6 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], full: bool) -> Result<()> { ) .await?; - if full { - // Clear sync tokens to force a full fetch - let _ = sqlx::query( - "UPDATE calendars SET sync_token = NULL, ctag = NULL WHERE source_id = ?", - ) - .bind(source_id) - .execute(pool) - .await; - } - if let Err(e) = sync_source(pool, key, &client, source_id).await { println!(" {} Sync failed: {}", "✗".red(), e); continue; @@ -288,8 +315,8 @@ pub async fn sync_if_stale(pool: &SqlitePool, key: &[u8; 32], user_id: &str) { // Must match SQLite datetime('now') format: "YYYY-MM-DD HH:MM:SS" (space, not T) let cutoff_str = cutoff.format("%Y-%m-%d %H:%M:%S").to_string(); - let stale_sources: Vec<(String, String, String, Option, String, Option, Option)> = sqlx::query_as( - "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.auth_type, cs.access_token_enc, cs.token_expires_at + let stale_sources: Vec<(String, String, String, Option, String, Option, Option, String)> = sqlx::query_as( + "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.auth_type, cs.access_token_enc, cs.token_expires_at, cs.provider_type FROM caldav_sources cs JOIN accounts a ON a.id = cs.account_id WHERE a.user_id = ? AND cs.enabled = 1 @@ -307,8 +334,16 @@ pub async fn sync_if_stale(pool: &SqlitePool, key: &[u8; 32], user_id: &str) { tracing::debug!(user_id = %user_id, "on-demand CalDAV sync triggered (stale >5min)"); - for (source_id, url, username, password_enc, auth_type, access_token_enc, token_expires_at) in - &stale_sources + for ( + source_id, + url, + username, + password_enc, + auth_type, + access_token_enc, + token_expires_at, + provider_type, + ) in &stale_sources { // Serialize on-demand syncs per source. If another task is already // syncing this source, we wait, then re-check staleness — almost @@ -335,6 +370,21 @@ pub async fn sync_if_stale(pool: &SqlitePool, key: &[u8; 32], user_id: &str) { continue; } + if provider_type == kinds::EWS { + let password = + match crate::crypto::decrypt_password(key, password_enc.as_deref().unwrap_or("")) { + Ok(p) => p, + Err(_) => continue, + }; + let provider = + match crate::providers::build_provider(provider_type, url, username, &password) { + Ok(p) => p, + Err(_) => continue, + }; + let _ = sync_ews_source(pool, key, provider.as_ref(), source_id).await; + continue; + } + let client = match crate::oauth2_caldav::build_client_for_source( pool, key, @@ -358,8 +408,8 @@ pub async fn sync_if_stale(pool: &SqlitePool, key: &[u8; 32], user_id: &str) { /// Sync a single source by ID (for background sync loop). /// Forces a full resync if last_full_sync is >24h ago (catches orphaned events). pub async fn sync_source_by_id(pool: &SqlitePool, key: &[u8; 32], source_id: &str) { - let source: Option<(String, String, Option, Option, String, Option, Option)> = sqlx::query_as( - "SELECT url, username, password_enc, last_full_sync, auth_type, access_token_enc, token_expires_at FROM caldav_sources WHERE id = ? AND enabled = 1", + let source: Option<(String, String, Option, Option, String, Option, Option, String)> = sqlx::query_as( + "SELECT url, username, password_enc, last_full_sync, auth_type, access_token_enc, token_expires_at, provider_type FROM caldav_sources WHERE id = ? AND enabled = 1", ) .bind(source_id) .fetch_optional(pool) @@ -374,6 +424,7 @@ pub async fn sync_source_by_id(pool: &SqlitePool, key: &[u8; 32], source_id: &st auth_type, access_token_enc, token_expires_at, + provider_type, )) = source else { return; @@ -397,6 +448,21 @@ pub async fn sync_source_by_id(pool: &SqlitePool, key: &[u8; 32], source_id: &st .await; } + if provider_type == kinds::EWS { + let password = + match crate::crypto::decrypt_password(key, password_enc.as_deref().unwrap_or("")) { + Ok(p) => p, + Err(_) => return, + }; + let provider = + match crate::providers::build_provider(&provider_type, &url, &username, &password) { + Ok(p) => p, + Err(_) => return, + }; + let _ = sync_ews_source(pool, key, provider.as_ref(), source_id).await; + return; + } + let client = match crate::oauth2_caldav::build_client_for_source( pool, key, @@ -416,6 +482,257 @@ pub async fn sync_source_by_id(pool: &SqlitePool, key: &[u8; 32], source_id: &st let _ = sync_source(pool, key, &client, source_id).await; } +/// EWS-specific sync path using the [`crate::providers::CalendarProvider`] +/// trait. CalDAV sources keep going through [`sync_source`], which retains +/// CalDAV-only optimisations (ctag, RFC 6578 sync-token, time-range queries, +/// hardened orphan reconciliation). The EWS path is intentionally simpler: +/// list folders, fetch each one in full, and reconcile by UID. Delta sync is a +/// known follow-up — see `EwsProvider::sync_delta`. +pub async fn sync_ews_source( + pool: &SqlitePool, + key: &[u8; 32], + provider: &dyn crate::providers::CalendarProvider, + source_id: &str, +) -> Result<()> { + let calendars = provider.list_calendars().await?; + + // Bounded fetch window. Matches the CalDAV path's FULL_FETCH_LOOKBACK_DAYS: + // 90 days back is plenty for orphan reconciliation and keeps EWS response + // sizes predictable. The provider's fetch_events_since uses CalendarView, + // which expands recurrences server-side within the window. + let since_dt = Utc::now() - chrono::Duration::days(FULL_FETCH_LOOKBACK_DAYS); + let since_iso = since_dt.to_rfc3339(); + let since_prefix = since_dt.format("%Y%m%d").to_string(); + + for cal_info in &calendars { + let (cal_id, _stored_change_marker, _stored_sync_state) = + upsert_calendar_provider(pool, source_id, cal_info).await?; + let cal_label = cal_info.display_name.as_deref().unwrap_or(&cal_info.id); + + match provider.fetch_events_since(&cal_info.id, &since_iso).await { + Ok(raw_events) => { + let count = upsert_provider_events(pool, &cal_id, &raw_events).await; + let deleted = + remove_orphaned_ews_events(pool, key, &cal_id, &raw_events, &since_prefix) + .await; + if deleted > 0 { + tracing::info!( + calendar_name = cal_label, + stale_events_removed = deleted, + "removed stale EWS events from local cache" + ); + } + println!( + " {} {} — {} event(s) synced{}", + "✓".green(), + cal_label, + count, + if deleted > 0 { + format!(", {} removed", deleted) + } else { + String::new() + } + ); + } + Err(e) => { + println!(" {} {} — failed: {}", "✗".red(), cal_label, e); + } + } + } + + let _ = sqlx::query("UPDATE caldav_sources SET last_full_sync = datetime('now') WHERE id = ?") + .bind(source_id) + .execute(pool) + .await; + sqlx::query("UPDATE caldav_sources SET last_synced = datetime('now') WHERE id = ?") + .bind(source_id) + .execute(pool) + .await?; + tracing::info!(source_id = %source_id, "EWS sync completed"); + Ok(()) +} + +/// Provider-trait equivalent of [`upsert_calendar`]. EWS uses opaque folder +/// IDs in the `href` column; the `id` field on `RemoteCalendar` is reused. +async fn upsert_calendar_provider( + pool: &SqlitePool, + source_id: &str, + cal_info: &crate::providers::RemoteCalendar, +) -> Result<(String, Option, Option)> { + let existing: Option<(String, Option, Option)> = sqlx::query_as( + "SELECT id, ctag, sync_token FROM calendars WHERE source_id = ? AND href = ?", + ) + .bind(source_id) + .bind(&cal_info.id) + .fetch_optional(pool) + .await?; + + match existing { + Some((id, ctag, sync_token)) => { + sqlx::query("UPDATE calendars SET display_name = ?, color = ? WHERE id = ?") + .bind(&cal_info.display_name) + .bind(&cal_info.color) + .bind(&id) + .execute(pool) + .await?; + Ok((id, ctag, sync_token)) + } + None => { + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO calendars (id, source_id, href, display_name, color, ctag) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(source_id) + .bind(&cal_info.id) + .bind(&cal_info.display_name) + .bind(&cal_info.color) + .bind(&cal_info.change_marker) + .execute(pool) + .await?; + Ok((id, None, None)) + } + } +} + +/// Provider-trait equivalent of [`upsert_raw_events`]. Splits the iCal blob +/// into VEVENTs and upserts into the `events` table (same composite key: +/// calendar_id + uid + recurrence_id). +async fn upsert_provider_events( + pool: &SqlitePool, + cal_id: &str, + raw_events: &[ProviderRawEvent], +) -> u32 { + let mut count = 0u32; + for raw in raw_events { + let vevent_blocks = split_vevents(&raw.ical); + for vevent in &vevent_blocks { + let uid = + extract_vevent_field(vevent, "UID").unwrap_or_else(|| Uuid::new_v4().to_string()); + let summary = extract_vevent_field(vevent, "SUMMARY"); + let start_at = extract_vevent_field(vevent, "DTSTART").unwrap_or_default(); + let end_at = extract_vevent_field(vevent, "DTEND").unwrap_or_default(); + let location = extract_vevent_field(vevent, "LOCATION"); + let description = extract_vevent_field(vevent, "DESCRIPTION"); + let status = extract_vevent_field(vevent, "STATUS"); + let rrule = extract_vevent_field(vevent, "RRULE"); + let recurrence_id = extract_vevent_field(vevent, "RECURRENCE-ID"); + let transp = extract_vevent_field(vevent, "TRANSP"); + let timezone = extract_vevent_tzid(vevent, "DTSTART"); + + let event_id = Uuid::new_v4().to_string(); + let _ = sqlx::query( + "INSERT INTO events (id, calendar_id, uid, summary, start_at, end_at, location, description, status, rrule, raw_ical, recurrence_id, timezone, transp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(calendar_id, uid, COALESCE(recurrence_id, '')) DO UPDATE SET + summary = excluded.summary, + start_at = excluded.start_at, + end_at = excluded.end_at, + location = excluded.location, + description = excluded.description, + status = excluded.status, + rrule = excluded.rrule, + raw_ical = excluded.raw_ical, + recurrence_id = excluded.recurrence_id, + timezone = excluded.timezone, + transp = excluded.transp, + synced_at = datetime('now')", + ) + .bind(&event_id) + .bind(cal_id) + .bind(&uid) + .bind(&summary) + .bind(&start_at) + .bind(&end_at) + .bind(&location) + .bind(&description) + .bind(&status) + .bind(&rrule) + .bind(&raw.ical) + .bind(&recurrence_id) + .bind(&timezone) + .bind(&transp) + .execute(pool) + .await; + + count += 1; + } + } + count +} + +/// EWS variant of orphan reconciliation, scoped to the fetched window. +/// `since_prefix` is a `YYYYMMDD` lower bound matching the +/// `fetch_events_since` call: events with `start_at` before it weren't in +/// the response and must not be flagged as orphans. Pass an empty string to +/// reconcile against every local event. +/// +/// `client = None` is implied: EWS sources can't be HTTP-verified against a +/// `CaldavClient`, so we go straight to DB cancellation when an event has +/// vanished from the server. +async fn remove_orphaned_ews_events( + pool: &SqlitePool, + key: &[u8; 32], + cal_id: &str, + raw_events: &[ProviderRawEvent], + since_prefix: &str, +) -> u32 { + let mut seen_uids: Vec<(String, String)> = Vec::new(); + for raw in raw_events { + for vevent in split_vevents(&raw.ical) { + let uid = + extract_vevent_field(&vevent, "UID").unwrap_or_else(|| Uuid::new_v4().to_string()); + let recurrence_id = extract_vevent_field(&vevent, "RECURRENCE-ID"); + seen_uids.push((uid, recurrence_id.unwrap_or_default())); + } + } + + if seen_uids.is_empty() { + return 0; + } + + // Same window-scoping trick as the CalDAV path: compact ("YYYYMMDDTHHMMSS") + // and all-day ("YYYYMMDD") start_at values both sort against a YYYYMMDD + // lower bound. + let local_events: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT id, uid, recurrence_id FROM events + WHERE calendar_id = ? + AND (? = '' OR start_at >= ?)", + ) + .bind(cal_id) + .bind(since_prefix) + .bind(since_prefix) + .fetch_all(pool) + .await + .unwrap_or_default(); + + let mut deleted = 0u32; + for (event_id, uid, recurrence_id) in &local_events { + let rec_id = recurrence_id.clone().unwrap_or_default(); + if !seen_uids.iter().any(|(u, r)| u == uid && r == &rec_id) { + let _ = sqlx::query("DELETE FROM events WHERE id = ?") + .bind(event_id) + .execute(pool) + .await; + cancel_orphaned_booking_simple(pool, key, uid).await; + deleted += 1; + } + } + deleted +} + +/// Simplified booking-cancel for EWS orphan reconciliation: looks up a +/// confirmed booking by UID and marks it cancelled. Skips the +/// `cancel_orphaned_booking` HTTP confirm step (CalDAV-specific). +async fn cancel_orphaned_booking_simple(pool: &SqlitePool, _key: &[u8; 32], uid: &str) { + let _ = sqlx::query( + "UPDATE bookings SET status = 'cancelled' WHERE uid = ? AND status = 'confirmed'", + ) + .bind(uid) + .execute(pool) + .await; +} + // --- Helper functions --- /// Upsert a calendar record and return (cal_id, stored_ctag, stored_sync_token) diff --git a/src/db.rs b/src/db.rs index 267e73b..a5571ce 100644 --- a/src/db.rs +++ b/src/db.rs @@ -232,6 +232,10 @@ pub async fn migrate(pool: &SqlitePool) -> Result<()> { include_str!("../migrations/053_oauth2_caldav.sql"), ), ("054_captcha", include_str!("../migrations/054_captcha.sql")), + ( + "055_provider_type", + include_str!("../migrations/055_provider_type.sql"), + ), ]; let mut applied_count = 0u32; @@ -839,7 +843,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count.0, 54, "All 54 migrations should be tracked"); + assert_eq!(count.0, 55, "All 55 migrations should be tracked"); } #[tokio::test] @@ -853,7 +857,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count.0, 54, "Still 54 migrations after second run"); + assert_eq!(count.0, 55, "Still 55 migrations after second run"); } #[tokio::test] diff --git a/src/ews/autodiscover.rs b/src/ews/autodiscover.rs new file mode 100644 index 0000000..4dab254 --- /dev/null +++ b/src/ews/autodiscover.rs @@ -0,0 +1,217 @@ +//! Exchange Autodiscover (POX / XML). +//! +//! Given an email address, derive the SOAP EWS endpoint by asking the user's +//! domain Autodiscover service. We follow the legacy POX flow described in +//! [MS-OXDSCLI]: try each candidate URL in order until one returns an XML +//! response containing `` (or `Protocol/EXCH` with an EWS attribute). +//! +//! The newer JSON v2 endpoint (`/autodiscover/autodiscover.json`) is mostly an +//! Office 365 thing; on-prem 2019 still serves POX, which is the priority +//! target for this implementation. + +use anyhow::{bail, Context, Result}; +use reqwest::redirect::Policy; +use std::time::Duration; + +use super::soap::first_tag_content; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// Try a sequence of Autodiscover URLs derived from `email_domain` and return +/// the EWS endpoint discovered, if any. The candidate order matches Microsoft +/// guidance: HTTPS root, then `autodiscover` subdomain, then unauthenticated +/// HTTP redirect, then SRV (DNS) — the SRV branch is left as a TODO since it +/// requires a DNS resolver. +pub async fn discover_ews_url(email: &str, password: &str) -> Result { + let domain = email + .rsplit('@') + .next() + .filter(|d| !d.is_empty()) + .context("autodiscover requires an email address with a domain")?; + + let candidates = [ + format!("https://autodiscover.{domain}/autodiscover/autodiscover.xml"), + format!("https://{domain}/autodiscover/autodiscover.xml"), + ]; + + let body = pox_request_body(email); + let client = reqwest::Client::builder() + .timeout(TIMEOUT) + // Follow up to two redirects — Autodiscover often replies 302 to a + // canonical URL on the same domain. + // + // Known limitation: the SSRF validator only runs on the initial + // candidate URL, not on intermediate Location headers. A malicious + // autodiscover responder could redirect to a private hostname mid + // chain. The chain is HTTPS, so the attacker would also need a + // valid cert for the private host; we accept the residual risk and + // rely on the egress firewall mitigation (see docs/src/security.md). + // The server-supplied EwsUrl from the final response is revalidated + // below before being persisted. + .redirect(Policy::limited(2)) + .build() + .context("HTTP client build failed")?; + + let mut last_err: Option = None; + for url in &candidates { + // Re-use the shared validator: rejects non-HTTPS and private/loopback + // hosts so a hostile email domain can't probe internal infrastructure. + if let Err(e) = crate::caldav::validate_caldav_url(url) { + tracing::debug!(candidate = %url, error = %e, "skipping autodiscover candidate (validator rejected)"); + continue; + } + match try_one(&client, url, email, password, &body).await { + Ok(Some(ews_url)) => { + // The server-supplied EwsUrl is later validated again when + // the source is persisted; double-check here for early + // failure with a helpful message. + if let Err(e) = crate::caldav::validate_caldav_url(&ews_url) { + last_err = Some(anyhow::anyhow!( + "Autodiscover returned an unsafe EWS URL ({ews_url}): {e}" + )); + continue; + } + return Ok(ews_url); + } + Ok(None) => continue, + Err(e) => last_err = Some(e), + } + } + + bail!( + "Autodiscover did not return an EWS endpoint for {email}. Tried: {}. Last error: {}", + candidates.join(", "), + last_err + .map(|e| e.to_string()) + .unwrap_or_else(|| "no response".to_string()), + ) +} + +async fn try_one( + client: &reqwest::Client, + url: &str, + username: &str, + password: &str, + body: &str, +) -> Result> { + let resp = client + .post(url) + .basic_auth(username, Some(password)) + .header("Content-Type", "text/xml; charset=utf-8") + .body(body.to_string()) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => { + tracing::debug!(url = %url, error = %e, "autodiscover candidate unreachable"); + return Ok(None); + } + }; + + let status = resp.status(); + if !status.is_success() { + tracing::debug!(url = %url, status = %status, "autodiscover candidate returned error"); + return Ok(None); + } + + let text = resp.text().await.unwrap_or_default(); + Ok(parse_pox_response(&text)) +} + +/// Build the standard POX Autodiscover request body. The schema is fixed by +/// Microsoft and `EMailAddress` is the only variable. +fn pox_request_body(email: &str) -> String { + format!( + r#" + + + {email} + http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a + +"#, + email = super::soap::escape(email), + ) +} + +/// Extract the EWS URL from an Autodiscover POX response. We accept either: +/// - the explicit `` element (most modern response shape) +/// - a `` with `EXPR` or `EXCH` and a +/// matching `` element nested inside +pub fn parse_pox_response(xml: &str) -> Option { + if let Some(url) = first_tag_content(xml, "EwsUrl") { + if !url.is_empty() { + return Some(url); + } + } + if let Some(url) = first_tag_content(xml, "ASUrl") { + // Some servers return the active sync URL in ASUrl which has the same host; + // build a sensible EWS URL from it as a last resort. + if let Ok(parsed) = reqwest::Url::parse(&url) { + return Some(format!( + "{}://{}/EWS/Exchange.asmx", + parsed.scheme(), + parsed.host_str()? + )); + } + } + None +} + +/// Without contacting the network, build the conventional EWS URL for a +/// domain. Useful as a fallback when Autodiscover is blocked but the admin +/// knows the canonical hostname. +pub fn conventional_ews_url(host: &str) -> String { + format!("https://{}/EWS/Exchange.asmx", host) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_modern_autodiscover_response() { + let xml = r#" + + + + + EXCH + https://mail.example.com/EWS/Exchange.asmx + https://mail.example.com/EWS/Exchange.asmx + + + +"#; + assert_eq!( + parse_pox_response(xml), + Some("https://mail.example.com/EWS/Exchange.asmx".to_string()) + ); + } + + #[test] + fn fallback_to_asurl() { + let xml = r#" + https://mail.example.com/Microsoft-Server-ActiveSync + "#; + assert_eq!( + parse_pox_response(xml), + Some("https://mail.example.com/EWS/Exchange.asmx".to_string()) + ); + } + + #[test] + fn no_match_returns_none() { + let xml = "NotFound"; + assert_eq!(parse_pox_response(xml), None); + } + + #[test] + fn conventional_url_format() { + assert_eq!( + conventional_ews_url("mail.example.com"), + "https://mail.example.com/EWS/Exchange.asmx", + ); + } +} diff --git a/src/ews/ical.rs b/src/ews/ical.rs new file mode 100644 index 0000000..11af1a5 --- /dev/null +++ b/src/ews/ical.rs @@ -0,0 +1,189 @@ +//! Convert EWS calendar items into iCalendar text. +//! +//! When sync responses ship MIME content we can extract the iCal block +//! verbatim (see [`super::parse::extract_vcalendar`]). For lighter `FindItem` +//! results we synthesise a minimal VEVENT from the structured fields. The +//! goal is feature parity with the CalDAV path: calrs only needs UID, start, +//! end, summary, location, status, and the all-day flag to compute slot +//! availability. + +use super::parse::EwsCalendarItem; + +/// Synthesise a minimal `BEGIN:VCALENDAR…END:VCALENDAR` block from a +/// `FindItem` result. Useful when the caller doesn't want a follow-up +/// `GetItem` round trip. +pub fn synth_vcalendar(item: &EwsCalendarItem) -> Option { + let uid = item + .uid + .clone() + .or_else(|| Some(item.item_id.clone())) + .filter(|s| !s.is_empty())?; + let start = item.start.clone()?; + let end = item.end.clone()?; + + let dtstart = format_dt(&start, item.is_all_day); + let dtend = format_dt(&end, item.is_all_day); + + let summary = item + .subject + .as_deref() + .map(escape_ical_text) + .unwrap_or_default(); + let location = item + .location + .as_deref() + .map(escape_ical_text) + .unwrap_or_default(); + + // calrs availability checks treat TRANSPARENT events as non-blocking. EWS + // exposes that intent via LegacyFreeBusyStatus. + let transp = match item.free_busy_status.as_deref() { + Some("Free") | Some("Tentative") => "TRANSPARENT", + _ => "OPAQUE", + }; + + let status = if item.is_cancelled { + "CANCELLED" + } else { + "CONFIRMED" + }; + + // RFC 5545 requires DTSTAMP on every VEVENT. EWS doesn't expose a stable + // "last modified" we can map to it, so we emit "now" — strict consumers + // get a valid VEVENT, calrs availability code ignores DTSTAMP. + let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); + + let mut buf = String::new(); + buf.push_str("BEGIN:VCALENDAR\r\n"); + buf.push_str("VERSION:2.0\r\n"); + buf.push_str("PRODID:-//calrs//ews-bridge//EN\r\n"); + buf.push_str("BEGIN:VEVENT\r\n"); + buf.push_str(&format!("UID:{uid}\r\n")); + buf.push_str(&format!("DTSTAMP:{dtstamp}\r\n")); + buf.push_str(&format!("DTSTART{dtstart}\r\n")); + buf.push_str(&format!("DTEND{dtend}\r\n")); + if !summary.is_empty() { + buf.push_str(&format!("SUMMARY:{summary}\r\n")); + } + if !location.is_empty() { + buf.push_str(&format!("LOCATION:{location}\r\n")); + } + buf.push_str(&format!("TRANSP:{transp}\r\n")); + buf.push_str(&format!("STATUS:{status}\r\n")); + buf.push_str("END:VEVENT\r\n"); + buf.push_str("END:VCALENDAR\r\n"); + Some(buf) +} + +/// Format an EWS datetime (`2026-05-06T09:00:00Z` or +/// `2026-05-08T00:00:00`) as the iCal property value, including the right +/// VALUE/TZID hint. EWS stores naive-local-with-timezone or UTC; the +/// generated iCal mirrors the source semantics. +/// +/// TODO: naive-local datetimes are currently emitted as floating values +/// (no `TZID` parameter). EWS normally returns UTC, but tenants with a +/// non-UTC default may surface naive locals, which would be misread as +/// the host's local time during availability checks. Recurring events +/// already escape via the MIME path; the gap is limited to non-recurring +/// naive-local items synthesised from `FindItem`. Tracking in the issue +/// tracker for a follow-up. +fn format_dt(value: &str, all_day: bool) -> String { + if all_day { + // All-day events use VALUE=DATE with YYYYMMDD. + let date = value.chars().take(10).collect::().replace('-', ""); + return format!(";VALUE=DATE:{}", date); + } + // EWS UTC is YYYY-MM-DDTHH:MM:SSZ → iCal UTC YYYYMMDDTHHMMSSZ. + let stripped = value.replace(['-', ':'], ""); + let stripped = stripped.trim().to_string(); + if stripped.ends_with('Z') { + format!(":{}", stripped) + } else { + // Local-only datetime; let downstream parsers treat as floating. + format!(":{}", stripped) + } +} + +/// Escape a string for embedding in an iCal TEXT property. Per RFC 5545: +/// backslash, comma, semicolon and newlines need escaping. +fn escape_ical_text(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '\\' => out.push_str("\\\\"), + ',' => out.push_str("\\,"), + ';' => out.push_str("\\;"), + '\n' => out.push_str("\\n"), + '\r' => {} + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(start: &str, end: &str, all_day: bool) -> EwsCalendarItem { + EwsCalendarItem { + item_id: "ID".to_string(), + change_key: None, + uid: Some("uid-1".to_string()), + subject: Some("Hello, world".to_string()), + start: Some(start.to_string()), + end: Some(end.to_string()), + location: Some("Room 1".to_string()), + is_all_day: all_day, + is_cancelled: false, + free_busy_status: Some("Busy".to_string()), + has_recurrence: false, + } + } + + #[test] + fn synth_basic_event() { + let it = item("2026-05-06T09:00:00Z", "2026-05-06T09:30:00Z", false); + let ics = synth_vcalendar(&it).unwrap(); + assert!(ics.contains("UID:uid-1")); + assert!(ics.contains("DTSTART:20260506T090000Z")); + assert!(ics.contains("DTEND:20260506T093000Z")); + // RFC 5545 escaping: comma must be backslash-escaped + assert!(ics.contains("SUMMARY:Hello\\, world")); + assert!(ics.contains("LOCATION:Room 1")); + assert!(ics.contains("TRANSP:OPAQUE")); + assert!(ics.contains("STATUS:CONFIRMED")); + } + + #[test] + fn synth_all_day() { + let it = item("2026-05-08T00:00:00", "2026-05-09T00:00:00", true); + let ics = synth_vcalendar(&it).unwrap(); + assert!(ics.contains("DTSTART;VALUE=DATE:20260508")); + assert!(ics.contains("DTEND;VALUE=DATE:20260509")); + } + + #[test] + fn synth_marks_free_as_transparent() { + let mut it = item("2026-05-06T09:00:00Z", "2026-05-06T09:30:00Z", false); + it.free_busy_status = Some("Free".to_string()); + let ics = synth_vcalendar(&it).unwrap(); + assert!(ics.contains("TRANSP:TRANSPARENT")); + } + + #[test] + fn synth_marks_cancelled_status() { + let mut it = item("2026-05-06T09:00:00Z", "2026-05-06T09:30:00Z", false); + it.is_cancelled = true; + let ics = synth_vcalendar(&it).unwrap(); + assert!(ics.contains("STATUS:CANCELLED")); + } + + #[test] + fn synth_uses_item_id_when_uid_missing() { + let mut it = item("2026-05-06T09:00:00Z", "2026-05-06T09:30:00Z", false); + it.uid = None; + let ics = synth_vcalendar(&it).unwrap(); + assert!(ics.contains("UID:ID")); + } +} diff --git a/src/ews/mod.rs b/src/ews/mod.rs new file mode 100644 index 0000000..efe53c2 --- /dev/null +++ b/src/ews/mod.rs @@ -0,0 +1,318 @@ +//! Microsoft Exchange Web Services (EWS) calendar provider. +//! +//! Targets on-prem **Exchange 2019** and earlier (2016, 2013) which all speak +//! the same SOAP protocol at `/EWS/Exchange.asmx`. The implementation +//! intentionally keeps surface area minimal: we discover calendar folders, +//! fetch / write events, and run delta sync — nothing more. Anything more +//! exotic (free/busy of other users, room booking, delegate access) belongs +//! in a follow-up PR. +//! +//! ## Authentication +//! +//! HTTP Basic over TLS. NTLM and Kerberos are common in on-prem environments +//! but require additional crates (`reqwest` does not natively negotiate +//! either). For now, admins should either enable Basic on a service mailbox +//! or place a reverse proxy in front that handles the negotiate handshake. +//! See `docs/ews.md` (planned) for setup details. +//! +//! ## Layout +//! +//! - `autodiscover` — POX Autodiscover lookup so users can configure a source +//! with just an email address. +//! - `soap` — envelope wrapping, basic auth, response parsing helpers. +//! - `operations` — typed wrappers for FindFolder, FindItem, GetItem, +//! CreateItem, DeleteItem, SyncFolderItems. +//! - `parse` — XML response decoders. +//! - `ical` — synthesise an iCalendar block from EWS structured fields, used +//! when MIME content is unavailable. +//! +//! The public surface is [`EwsProvider`], which implements +//! [`crate::providers::CalendarProvider`]. + +pub mod autodiscover; +pub mod ical; +pub mod operations; +pub mod parse; +pub mod soap; + +use anyhow::Result; +use async_trait::async_trait; +use std::collections::HashMap; + +use crate::providers::{CalendarProvider, DeltaResult, RawEvent, RemoteCalendar}; + +/// EWS-backed calendar provider. Constructed from the SOAP endpoint URL plus +/// credentials. Designed to be cheap to clone — no HTTP client cached on the +/// instance because each request rebuilds one with the appropriate timeout. +pub struct EwsProvider { + endpoint: String, + username: String, + password: String, +} + +impl EwsProvider { + pub fn new(endpoint: &str, username: &str, password: &str) -> Self { + Self { + endpoint: endpoint.trim_end_matches('/').to_string(), + username: username.to_string(), + password: password.to_string(), + } + } + + /// Validate the URL the same way the CalDAV path does (HTTPS only, + /// no SSRF-prone hostnames). Re-exported here so the source-add flow can + /// validate before persisting. + pub fn validate_url(url: &str) -> Result<()> { + crate::caldav::validate_caldav_url(url) + } +} + +#[async_trait] +impl CalendarProvider for EwsProvider { + async fn check_connection(&self) -> Result { + operations::check_connection(&self.endpoint, &self.username, &self.password).await + } + + async fn list_calendars(&self) -> Result> { + let folders = + operations::list_calendar_folders(&self.endpoint, &self.username, &self.password) + .await?; + Ok(folders + .into_iter() + .map(|f| RemoteCalendar { + id: f.id, + display_name: f.display_name, + color: None, + change_marker: f.change_key, + sync_state: None, + }) + .collect()) + } + + async fn fetch_events(&self, calendar_id: &str) -> Result> { + let items = + operations::list_items(&self.endpoint, &self.username, &self.password, calendar_id) + .await?; + Ok(synth_or_fetch_mime(self, items).await) + } + + async fn fetch_events_since( + &self, + calendar_id: &str, + since_utc: &str, + ) -> Result> { + // CalendarView wants both endpoints; pick a generous upper bound far + // enough out to cover every booking horizon calrs supports today. + // The 2-year window matches what the slot picker exposes — anything + // beyond that is going to be replaced by a fresh sync long before it + // becomes relevant. + let end_utc = upper_bound_iso(since_utc); + let items = operations::list_items_in_window( + &self.endpoint, + &self.username, + &self.password, + calendar_id, + since_utc, + &end_utc, + ) + .await?; + Ok(synth_or_fetch_mime(self, items).await) + } + + async fn sync_delta(&self, calendar_id: &str, sync_state: Option<&str>) -> Result { + // Cursor-seeding mode (see trait docs): the caller has already + // populated the local cache via `fetch_events` and only wants a + // starting cursor. EWS's `SyncFolderItems` without a state walks + // the entire folder before returning one, which is prohibitively + // costly on large mailboxes (and there's no smaller cursor-only + // EWS call to swap in). For now, EWS sources rely on full fetches + // via `fetch_events` — `stored_sync_state` stays `None` and every + // sync re-fetches the folder. The follow-up is to swap in a + // `CalendarView`-based incremental sync, see issue tracker. + if sync_state.is_none() { + return Ok(DeltaResult::default()); + } + + let delta = operations::sync_folder_items( + &self.endpoint, + &self.username, + &self.password, + calendar_id, + sync_state, + ) + .await?; + + // Real incremental sync: resolve added/changed items into iCal text. + // We pull MIME so we get full RRULE / EXDATE fidelity. + let ids: Vec<&str> = delta + .added_or_changed + .iter() + .map(|(id, _uid)| id.as_str()) + .collect(); + let mime_pairs = if ids.is_empty() { + Vec::new() + } else { + operations::get_items_mime(&self.endpoint, &self.username, &self.password, &ids).await? + }; + // Index MIME by ItemId so the order matches. + let mut mime_by_id: HashMap = mime_pairs.into_iter().collect(); + + let mut added_or_changed = Vec::with_capacity(delta.added_or_changed.len()); + for (id, _uid) in delta.added_or_changed { + if let Some(ical) = mime_by_id.remove(&id) { + added_or_changed.push(RawEvent { + remote_id: id, + ical, + }); + } + } + + // For deleted items, EWS gives us the ItemId; the iCalendar UID is + // not surfaced in the Delete change. calrs's orphan sweep (driven + // off the local `events` table) catches anything we miss here. + let deleted_uids = delta.deleted_item_ids; + + Ok(DeltaResult { + added_or_changed, + deleted_uids, + new_sync_state: delta.new_sync_state, + }) + } + + async fn put_event(&self, calendar_id: &str, uid: &str, ics: &str) -> Result<()> { + // EWS does not expose a true PUT-by-UID operation. The convention is + // to look the UID up via FindItem, delete the existing entry (if + // any), then create the new item. + let existing = operations::find_items_by_uid( + &self.endpoint, + &self.username, + &self.password, + calendar_id, + uid, + ) + .await + .unwrap_or_default(); + for item_id in &existing { + if let Err(e) = + operations::delete_item(&self.endpoint, &self.username, &self.password, item_id) + .await + { + tracing::warn!(uid = %uid, error = %e, "EWS could not delete prior copy before re-create; continuing"); + } + } + operations::create_item_from_ics( + &self.endpoint, + &self.username, + &self.password, + calendar_id, + ics, + ) + .await?; + Ok(()) + } + + async fn delete_event(&self, calendar_id: &str, uid: &str) -> Result<()> { + let existing = operations::find_items_by_uid( + &self.endpoint, + &self.username, + &self.password, + calendar_id, + uid, + ) + .await?; + for item_id in &existing { + operations::delete_item(&self.endpoint, &self.username, &self.password, item_id) + .await?; + } + Ok(()) + } +} + +/// For each FindItem result, prefer the synthesised iCal (no extra round trip) +/// when sufficient. Recurring items need GetItem to surface RRULE / EXDATE +/// fidelity, so for those we fall back to a MIME fetch. +async fn synth_or_fetch_mime( + provider: &EwsProvider, + items: Vec, +) -> Vec { + let mut out = Vec::with_capacity(items.len()); + let mut needs_mime: Vec = Vec::new(); + + for item in &items { + if item.has_recurrence { + needs_mime.push(item.item_id.clone()); + continue; + } + if let Some(ics) = ical::synth_vcalendar(item) { + out.push(RawEvent { + remote_id: item.item_id.clone(), + ical: ics, + }); + } + } + + if !needs_mime.is_empty() { + let id_refs: Vec<&str> = needs_mime.iter().map(String::as_str).collect(); + match operations::get_items_mime( + &provider.endpoint, + &provider.username, + &provider.password, + &id_refs, + ) + .await + { + Ok(pairs) => { + for (id, ical) in pairs { + out.push(RawEvent { + remote_id: id, + ical, + }); + } + } + Err(e) => { + tracing::warn!(error = %e, "EWS MIME fetch failed; recurring items missing"); + } + } + } + out +} + +/// Compute a far-enough upper bound for `CalendarView`. The input is the +/// caller's `since_utc` ISO 8601 string; we add roughly two years (the +/// horizon over which calrs ever needs free/busy data) and reformat as +/// RFC 3339 UTC. Anything we cannot parse falls back to "now + 2y". +fn upper_bound_iso(since_utc: &str) -> String { + use chrono::{DateTime, Duration, Utc}; + + if let Ok(parsed) = DateTime::parse_from_rfc3339(since_utc) { + return (parsed + Duration::days(730)) + .with_timezone(&Utc) + .to_rfc3339(); + } + (Utc::now() + Duration::days(730)).to_rfc3339() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upper_bound_extends_two_years() { + let bound = upper_bound_iso("2026-05-06T00:00:00Z"); + // The result must be ~2 years after the input — basic sanity that the math works. + assert!(bound.starts_with("2028-")); + } + + #[test] + fn upper_bound_falls_back_for_garbage_input() { + let bound = upper_bound_iso("not-a-date"); + // Must still be parseable as RFC 3339. + assert!(chrono::DateTime::parse_from_rfc3339(&bound).is_ok()); + } + + #[test] + fn ews_provider_trims_trailing_slash() { + let p = EwsProvider::new("https://mail.example.com/EWS/Exchange.asmx/", "u", "p"); + assert_eq!(p.endpoint, "https://mail.example.com/EWS/Exchange.asmx"); + } +} diff --git a/src/ews/operations.rs b/src/ews/operations.rs new file mode 100644 index 0000000..07b96ec --- /dev/null +++ b/src/ews/operations.rs @@ -0,0 +1,381 @@ +//! High-level EWS operations: list calendar folders, fetch / create / delete +//! calendar items, run an incremental sync. +//! +//! Each function builds a SOAP body, posts it via [`super::soap::post_soap`], +//! and parses the response with helpers from [`super::parse`]. Operations are +//! kept narrow on purpose — calrs only exercises a small slice of the EWS +//! surface. + +use anyhow::{bail, Context, Result}; + +use super::parse::{ + parse_calendar_items_response, parse_create_item_response, parse_find_folder_response, + parse_get_item_response, parse_sync_folder_items_response, EwsCalendarFolder, EwsCalendarItem, + EwsSyncDelta, +}; +use super::soap::{escape, post_soap}; + +/// Issue a `GetFolder` against `inbox` to confirm that the credentials are +/// valid and the endpoint speaks EWS. The folder is purely a convenience pick +/// — every Exchange mailbox has it. +pub async fn check_connection(endpoint: &str, username: &str, password: &str) -> Result { + let body = r#" + + IdOnly + + + + + +"#; + let resp = post_soap(endpoint, username, password, body, false).await?; + // post_soap already raises on SOAP faults; we only need to confirm the + // response carries a real folder reference (or, as a softer fallback, the + // standard Success class) before declaring the endpoint EWS-compatible. + if resp.contains("FolderId") || resp.contains("ResponseClass=\"Success\"") { + Ok(true) + } else { + bail!("EWS GetFolder returned no FolderId — server may not be EWS-compatible") + } +} + +/// Enumerate the user's calendar folders by walking down from +/// `msgfolderroot`. We restrict to `IPF.Appointment` containers. +pub async fn list_calendar_folders( + endpoint: &str, + username: &str, + password: &str, +) -> Result> { + let body = r#" + + Default + + + + + + + + + + + + + + + + + +"#; + let resp = post_soap(endpoint, username, password, body, false).await?; + parse_find_folder_response(&resp) +} + +/// Fetch every calendar item in a folder. Uses paging +/// (`IndexedPageItemView`) with a max page size to avoid OOM on busy +/// mailboxes. +pub async fn list_items( + endpoint: &str, + username: &str, + password: &str, + folder_id: &str, +) -> Result> { + list_items_window(endpoint, username, password, folder_id, None, None).await +} + +/// Fetch calendar items overlapping `[start, end)`. Both bounds are RFC 3339 +/// UTC strings (e.g. `2026-05-06T00:00:00Z`). EWS exposes this as a +/// `CalendarView` query, which expands recurring series into their +/// occurrences in the window — handy for booking slot computation. +pub async fn list_items_in_window( + endpoint: &str, + username: &str, + password: &str, + folder_id: &str, + start_utc: &str, + end_utc: &str, +) -> Result> { + list_items_window( + endpoint, + username, + password, + folder_id, + Some(start_utc), + Some(end_utc), + ) + .await +} + +const PAGE_SIZE: u32 = 200; + +async fn list_items_window( + endpoint: &str, + username: &str, + password: &str, + folder_id: &str, + start_utc: Option<&str>, + end_utc: Option<&str>, +) -> Result> { + let mut all = Vec::new(); + let mut offset: u32 = 0; + loop { + let view = if let (Some(s), Some(e)) = (start_utc, end_utc) { + // CalendarView expands recurrences; no offset paging is needed + // because EWS returns up to 1000 items in one shot for a window. + format!( + r#""#, + s = escape(s), + e = escape(e), + ) + } else { + format!( + r#""#, + ) + }; + + let body = format!( + r#" + + IdOnly + + + + + + + + + + + + + {view} + + + + +"#, + folder = escape(folder_id), + ); + let resp = post_soap(endpoint, username, password, &body, true).await?; + let mut page = parse_calendar_items_response(&resp)?; + + let included = page.included_count; + let total = page.total; + all.append(&mut page.items); + + // CalendarView returns everything in one shot. + if start_utc.is_some() { + break; + } + if included == 0 { + break; + } + offset += included as u32; + if let Some(t) = total { + if offset >= t as u32 { + break; + } + } else if (included as u32) < PAGE_SIZE { + break; + } + } + tracing::debug!(folder = %folder_id, count = all.len(), "EWS FindItem complete"); + Ok(all) +} + +/// Fetch the MIME (RFC 5322 + iCalendar) representation of one or more items. +/// Returns the items in the same order, dropping any that the server failed +/// to retrieve. +pub async fn get_items_mime( + endpoint: &str, + username: &str, + password: &str, + item_ids: &[&str], +) -> Result> { + if item_ids.is_empty() { + return Ok(Vec::new()); + } + let mut id_xml = String::new(); + for id in item_ids { + id_xml.push_str(&format!( + r#" +"#, + escape(id) + )); + } + let body = format!( + r#" + + IdOnly + true + + + + + +{id_xml} + +"#, + ); + let resp = post_soap(endpoint, username, password, &body, true).await?; + parse_get_item_response(&resp) +} + +/// Create a calendar item from an iCalendar payload. Exchange will store the +/// MIME blob and surface the event natively via OWA / Outlook. +/// +/// `SendMeetingInvitations="SendToNone"` keeps the call non-disruptive — calrs +/// drives invitations through SMTP separately and does not want EWS to fire +/// off duplicate invites on every booking. +pub async fn create_item_from_ics( + endpoint: &str, + username: &str, + password: &str, + folder_id: &str, + ics: &str, +) -> Result { + use base64::Engine; + let mime = base64::engine::general_purpose::STANDARD.encode(ics.as_bytes()); + + let body = format!( + r#" + + + + + + {mime} + + + +"#, + folder = escape(folder_id), + ); + let resp = post_soap(endpoint, username, password, &body, true).await?; + let item_id = parse_create_item_response(&resp)?; + Ok(item_id) +} + +/// Find calendar items that match a specific iCalendar UID. EWS exposes +/// `calendar:UID` as a searchable field; the result is usually 0 or 1 item. +pub async fn find_items_by_uid( + endpoint: &str, + username: &str, + password: &str, + folder_id: &str, + uid: &str, +) -> Result> { + let body = format!( + r#" + + IdOnly + + + + + + + + + + + + + +"#, + uid = escape(uid), + folder = escape(folder_id), + ); + let resp = post_soap(endpoint, username, password, &body, false).await?; + let parsed = parse_calendar_items_response(&resp)?; + Ok(parsed.items.into_iter().map(|i| i.item_id).collect()) +} + +/// Permanently delete an item by id. We use `HardDelete` because the +/// alternative (`MoveToDeletedItems`) would leave a tombstone in Trash that +/// can confuse free/busy on shared calendars. +pub async fn delete_item( + endpoint: &str, + username: &str, + password: &str, + item_id: &str, +) -> Result<()> { + let body = format!( + r#" + + + + +"#, + id = escape(item_id), + ); + let _ = post_soap(endpoint, username, password, &body, false).await?; + Ok(()) +} + +/// Run a delta sync (`SyncFolderItems`). +/// +/// `sync_state` is the opaque cursor returned by the previous call (or +/// `None` for the initial sync). We request `MaxChangesReturned=512` and +/// loop until the server reports `IncludesLastItemInRange=true`, with a +/// hard cap of `MAX_SYNC_PAGES` iterations as insurance against a server +/// that never sets the terminator (200 pages × 512 changes = 102 400 +/// items, far above any realistic mailbox). +pub async fn sync_folder_items( + endpoint: &str, + username: &str, + password: &str, + folder_id: &str, + sync_state: Option<&str>, +) -> Result { + const MAX_SYNC_PAGES: usize = 200; + let mut state = sync_state.map(str::to_string); + let mut all = EwsSyncDelta::default(); + for _ in 0..MAX_SYNC_PAGES { + let state_xml = state + .as_deref() + .map(|s| format!(" {}\n", escape(s))) + .unwrap_or_default(); + let body = format!( + r#" + + IdOnly + + + + + + + +{state_xml} 512 + NormalItems + +"#, + folder = escape(folder_id), + ); + let resp = post_soap(endpoint, username, password, &body, true).await?; + let page = parse_sync_folder_items_response(&resp) + .context("failed to parse SyncFolderItems response")?; + + all.added_or_changed.extend(page.added_or_changed); + all.deleted_uids.extend(page.deleted_uids); + all.deleted_item_ids.extend(page.deleted_item_ids); + all.new_sync_state = page.new_sync_state; + state = all.new_sync_state.clone(); + + if page.includes_last { + return Ok(all); + } + } + // Hit the safety cap without seeing IncludesLastItemInRange=true: either + // the server is broken or the mailbox is genuinely enormous. Return what + // we have plus the latest cursor so the next sync can resume. + tracing::warn!( + folder_id = %folder_id, + pages = MAX_SYNC_PAGES, + "SyncFolderItems hit the safety cap without IncludesLastItemInRange=true; \ + returning partial result, next sync will resume from cursor" + ); + Ok(all) +} diff --git a/src/ews/parse.rs b/src/ews/parse.rs new file mode 100644 index 0000000..c2e9538 --- /dev/null +++ b/src/ews/parse.rs @@ -0,0 +1,441 @@ +//! Response parsers for the EWS operations defined in [`super::operations`]. +//! +//! The parsing strategy matches the rest of calrs: deterministic SOAP shapes +//! are extracted with the namespace-agnostic helpers in [`super::soap`]. We +//! do not pull in a full XML library — EWS payloads are structured but +//! consistent enough that targeted extraction is reliable, and the test +//! suite (alongside `cargo clippy`) catches regressions. + +use anyhow::{bail, Result}; +use base64::Engine; + +use super::soap::{attr, collect_blocks, collect_tag_contents, first_tag_content}; + +/// One calendar folder discovered via `FindFolder`. +#[derive(Debug, Clone)] +pub struct EwsCalendarFolder { + pub id: String, + pub change_key: Option, + pub display_name: Option, +} + +/// Calendar item metadata returned by `FindItem` (no MIME content yet — that +/// requires a follow-up `GetItem`). +#[derive(Debug, Clone)] +pub struct EwsCalendarItem { + pub item_id: String, + pub change_key: Option, + pub uid: Option, + pub subject: Option, + pub start: Option, + pub end: Option, + pub location: Option, + pub is_all_day: bool, + pub is_cancelled: bool, + pub free_busy_status: Option, + pub has_recurrence: bool, +} + +/// Page of calendar items + total count + how many were included in this +/// response, so the caller can drive offset paging. +#[derive(Debug, Clone, Default)] +pub struct EwsItemPage { + pub items: Vec, + pub total: Option, + pub included_count: usize, +} + +/// Outcome of `SyncFolderItems` — added / changed items (with iCal text), and +/// deleted item ids/UIDs. +#[derive(Debug, Clone, Default)] +pub struct EwsSyncDelta { + pub added_or_changed: Vec<(String, String)>, + pub deleted_uids: Vec, + pub deleted_item_ids: Vec, + pub new_sync_state: Option, + pub includes_last: bool, +} + +/// Parse a `FindFolderResponse` body into a vector of calendar folders. +pub fn parse_find_folder_response(xml: &str) -> Result> { + let mut out = Vec::new(); + for block in collect_blocks(xml, "CalendarFolder") { + let id_tag = find_first_open_tag(&block, "FolderId"); + let (id, change_key) = match id_tag { + Some(tag) => ( + attr(&tag, "Id").unwrap_or_default(), + attr(&tag, "ChangeKey"), + ), + None => continue, + }; + if id.is_empty() { + continue; + } + let display_name = first_tag_content(&block, "DisplayName"); + out.push(EwsCalendarFolder { + id, + change_key, + display_name, + }); + } + Ok(out) +} + +/// Parse a `FindItemResponse` body for calendar items. +pub fn parse_calendar_items_response(xml: &str) -> Result { + let mut page = EwsItemPage::default(); + + // RootFolder TotalItemsInView / IncludesLastItemInRange / IndexedPagingOffset + if let Some(root_tag) = find_first_open_tag(xml, "RootFolder") { + if let Some(total) = attr(&root_tag, "TotalItemsInView") { + page.total = total.parse().ok(); + } + } + + let item_blocks = collect_blocks(xml, "CalendarItem"); + page.included_count = item_blocks.len(); + for block in item_blocks { + if let Some(item) = parse_calendar_item_block(&block) { + page.items.push(item); + } + } + Ok(page) +} + +fn parse_calendar_item_block(block: &str) -> Option { + let id_tag = find_first_open_tag(block, "ItemId")?; + let item_id = attr(&id_tag, "Id").unwrap_or_default(); + if item_id.is_empty() { + return None; + } + let change_key = attr(&id_tag, "ChangeKey"); + let uid = first_tag_content(block, "UID"); + let subject = first_tag_content(block, "Subject"); + let start = first_tag_content(block, "Start"); + let end = first_tag_content(block, "End"); + let location = first_tag_content(block, "Location"); + let is_all_day = first_tag_content(block, "IsAllDayEvent") + .map(|s| s.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let is_cancelled = first_tag_content(block, "IsCancelled") + .map(|s| s.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let free_busy_status = first_tag_content(block, "LegacyFreeBusyStatus"); + let has_recurrence = block.contains("Recurrence>"); + Some(EwsCalendarItem { + item_id, + change_key, + uid, + subject, + start, + end, + location, + is_all_day, + is_cancelled, + free_busy_status, + has_recurrence, + }) +} + +/// Parse a `GetItemResponse` body. Returns one (item_id, ical) pair per item +/// successfully retrieved. Items without MIME content are skipped (the server +/// occasionally returns a placeholder for unsupported types). +pub fn parse_get_item_response(xml: &str) -> Result> { + let mut out = Vec::new(); + let blocks = collect_blocks(xml, "CalendarItem"); + let blocks = if blocks.is_empty() { + // Some servers return MeetingRequest etc. + let mut alt = collect_blocks(xml, "MeetingRequest"); + alt.extend(collect_blocks(xml, "MeetingResponse")); + alt.extend(collect_blocks(xml, "MeetingCancellation")); + alt + } else { + blocks + }; + + for block in blocks { + let id_tag = match find_first_open_tag(&block, "ItemId") { + Some(t) => t, + None => continue, + }; + let item_id = attr(&id_tag, "Id").unwrap_or_default(); + if item_id.is_empty() { + continue; + } + let mime_b64 = match first_tag_content(&block, "MimeContent") { + Some(s) => s, + None => continue, + }; + let decoded = match base64::engine::general_purpose::STANDARD.decode(mime_b64.trim()) { + Ok(bytes) => bytes, + Err(e) => { + tracing::warn!(error = %e, "EWS MimeContent base64 decode failed; skipping item"); + continue; + } + }; + let mime_text = match String::from_utf8(decoded) { + Ok(t) => t, + Err(e) => { + tracing::warn!(error = %e, "EWS MimeContent not valid UTF-8; skipping item"); + continue; + } + }; + if let Some(ical) = extract_vcalendar(&mime_text) { + out.push((item_id, ical)); + } + } + Ok(out) +} + +/// Pull the BEGIN:VCALENDAR…END:VCALENDAR block out of a MIME message body. +/// EWS returns the full RFC 5322 envelope plus calendar attachment; we only +/// care about the iCalendar portion. +pub fn extract_vcalendar(mime: &str) -> Option { + let begin = mime.find("BEGIN:VCALENDAR")?; + let end = mime.find("END:VCALENDAR")?; + if end <= begin { + return None; + } + let close = end + "END:VCALENDAR".len(); + let mut block = mime[begin..close].to_string(); + // MIME line endings are CRLF; iCal already requires CRLF, so we leave it + // alone. RFC 5545 §3.1 long-content lines are folded with a CRLF followed + // by a single space or tab; unfold them here so downstream parsers see + // logical lines rather than the wire format. + block = block.replace("\r\n ", ""); + block = block.replace("\r\n\t", ""); + Some(block) +} + +/// Parse the response of `CreateItem` and return the new ItemId. +pub fn parse_create_item_response(xml: &str) -> Result { + if let Some(tag) = find_first_open_tag(xml, "ItemId") { + if let Some(id) = attr(&tag, "Id") { + if !id.is_empty() { + return Ok(id); + } + } + } + bail!("CreateItem response did not include an ItemId") +} + +/// Parse `SyncFolderItemsResponse` body (single page). Caller loops until +/// `includes_last` is true. +pub fn parse_sync_folder_items_response(xml: &str) -> Result { + let new_sync_state = first_tag_content(xml, "SyncState"); + let includes_last = first_tag_content(xml, "IncludesLastItemInRange") + .map(|s| s.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + let mut delta = EwsSyncDelta { + new_sync_state, + includes_last, + ..Default::default() + }; + + // Changes come as + // or with the same shape, and . + for block in collect_blocks(xml, "Create") { + if let Some(item) = item_id_with_uid(&block) { + delta.added_or_changed.push(item); + } + } + for block in collect_blocks(xml, "Update") { + if let Some(item) = item_id_with_uid(&block) { + delta.added_or_changed.push(item); + } + } + for block in collect_blocks(xml, "Delete") { + if let Some(tag) = find_first_open_tag(&block, "ItemId") { + if let Some(id) = attr(&tag, "Id") { + if !id.is_empty() { + delta.deleted_item_ids.push(id); + } + } + } + } + // ReadFlagChange is a sync change type that doesn't matter for calendars + // but EWS may emit it if we ever sync read flags — silently ignore. + Ok(delta) +} + +fn item_id_with_uid(block: &str) -> Option<(String, String)> { + let id = find_first_open_tag(block, "ItemId").and_then(|t| attr(&t, "Id"))?; + let uid = first_tag_content(block, "UID").unwrap_or_default(); + Some((id, uid)) +} + +/// Locate the opening tag (with attributes) of `local_name` and return it as +/// a substring like ``. Used to pull +/// attributes out without dragging in a real XML parser. +pub fn find_first_open_tag(xml: &str, local_name: &str) -> Option { + let needle = format!(":{local_name}"); + let mut search_from = 0; + while let Some(pos) = xml[search_from..].find(&needle) { + let abs = search_from + pos; + let before = &xml[..abs]; + let lt = match before.rfind('<') { + Some(i) => i, + None => { + search_from = abs + needle.len(); + continue; + } + }; + let prefix_part = &xml[lt + 1..abs]; + if prefix_part.is_empty() + || prefix_part.len() > 16 + || !prefix_part.chars().all(|c| c.is_alphanumeric() || c == '_') + { + search_from = abs + needle.len(); + continue; + } + let open_tag_end = abs + needle.len(); + if let Some(close) = xml[open_tag_end..].find('>') { + let end = open_tag_end + close + 1; + return Some(xml[lt..end].to_string()); + } + break; + } + None +} + +/// Convenience for diagnostics: count how many response messages succeeded +/// vs failed in a batched response. +pub fn count_response_messages(xml: &str) -> (usize, usize) { + let codes = collect_tag_contents(xml, "ResponseCode"); + let total = codes.len(); + let ok = codes.iter().filter(|c| c.as_str() == "NoError").count(); + (ok, total) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_find_folder_response() { + let xml = r#" + + + + + Calendar + + + + Birthdays + + + +"#; + let folders = parse_find_folder_response(xml).unwrap(); + assert_eq!(folders.len(), 2); + assert_eq!(folders[0].id, "AAMkADUw"); + assert_eq!(folders[0].change_key, Some("CK1".into())); + assert_eq!(folders[0].display_name.as_deref(), Some("Calendar")); + assert_eq!(folders[1].id, "AAMkADUx"); + assert_eq!(folders[1].display_name.as_deref(), Some("Birthdays")); + } + + #[test] + fn parses_find_item_calendar_view() { + let xml = r#" + + + + + Sync sync + 2026-05-06T09:00:00Z + 2026-05-06T09:30:00Z + Room 1 + false + false + Busy + uid-1 + + + + All-day off + 2026-05-08T00:00:00Z + 2026-05-09T00:00:00Z + true + OOF + uid-2 + + + +"#; + let page = parse_calendar_items_response(xml).unwrap(); + assert_eq!(page.total, Some(2)); + assert_eq!(page.items.len(), 2); + assert_eq!(page.items[0].item_id, "AB1"); + assert_eq!(page.items[0].uid.as_deref(), Some("uid-1")); + assert_eq!(page.items[0].subject.as_deref(), Some("Sync sync")); + assert_eq!(page.items[0].start.as_deref(), Some("2026-05-06T09:00:00Z")); + assert!(!page.items[0].is_all_day); + assert!(page.items[1].is_all_day); + assert_eq!(page.items[1].free_busy_status.as_deref(), Some("OOF")); + } + + #[test] + fn parses_create_item_response_returns_id() { + let xml = r#" + + + + + +"#; + assert_eq!(parse_create_item_response(xml).unwrap(), "NEW123"); + } + + #[test] + fn extract_vcalendar_from_mime() { + let mime = "MIME-Version: 1.0\r\nContent-Type: text/calendar\r\n\r\nBEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:abc@example.com\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + let block = extract_vcalendar(mime).unwrap(); + assert!(block.starts_with("BEGIN:VCALENDAR")); + assert!(block.ends_with("END:VCALENDAR")); + assert!(block.contains("UID:abc@example.com")); + } + + #[test] + fn parses_sync_delta_with_create_update_delete() { + let xml = r#" + STATE2 + true + + + + + uid-new + + + + + + uid-upd + + + + + + +"#; + let d = parse_sync_folder_items_response(xml).unwrap(); + assert_eq!(d.new_sync_state.as_deref(), Some("STATE2")); + assert!(d.includes_last); + assert_eq!(d.added_or_changed.len(), 2); + assert_eq!(d.added_or_changed[0].0, "N1"); + assert_eq!(d.added_or_changed[0].1, "uid-new"); + assert_eq!(d.deleted_item_ids, vec!["GONE"]); + } + + #[test] + fn count_response_messages_works() { + let xml = r#" + NoError + ErrorAccessDenied + "#; + assert_eq!(count_response_messages(xml), (1, 2)); + } +} diff --git a/src/ews/soap.rs b/src/ews/soap.rs new file mode 100644 index 0000000..1fb2de0 --- /dev/null +++ b/src/ews/soap.rs @@ -0,0 +1,392 @@ +//! Low-level SOAP transport for EWS. +//! +//! EWS exposes a SOAP 1.1 endpoint at `/EWS/Exchange.asmx`. Requests +//! are XML envelopes wrapping a `` (message) operation; responses come +//! back as similar envelopes carrying a `` body. Most calrs +//! operations are simple enough that we build the envelope as a string and +//! parse the response with the same string-based extractor used in +//! `src/caldav/mod.rs` — that keeps the dependency surface tight and matches +//! the existing house style. +//! +//! Authentication uses HTTP Basic over TLS. NTLM and Kerberos are common in +//! on-prem Exchange but require external crates; on-prem 2019 admins can +//! enable Basic on a service mailbox or front EWS with a reverse proxy that +//! does the negotiation for us. + +use anyhow::{bail, Context, Result}; +use reqwest::{Client, StatusCode}; +use std::time::Duration; + +/// Targeted EWS schema version. Exchange 2019 advertises up to +/// `Exchange2016` in its RequestServerVersion enumeration; using a value the +/// server understands is mandatory or it returns `ErrorInvalidServerVersion`. +pub const REQUEST_SERVER_VERSION: &str = "Exchange2016"; + +/// Default timeouts. Discovery is cheap; item fetches can paginate so we +/// allow a generous budget. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const FETCH_TIMEOUT: Duration = Duration::from_secs(120); + +/// XML namespaces used in every EWS envelope. +pub const NS_SOAP: &str = "http://schemas.xmlsoap.org/soap/envelope/"; +pub const NS_TYPES: &str = "http://schemas.microsoft.com/exchange/services/2006/types"; +pub const NS_MESSAGES: &str = "http://schemas.microsoft.com/exchange/services/2006/messages"; + +/// Wrap a SOAP body in a complete envelope with the standard headers. +pub fn envelope(body: &str) -> String { + format!( + r#" + + + + + +{body} + +"#, + ns_soap = NS_SOAP, + ns_types = NS_TYPES, + ns_messages = NS_MESSAGES, + version = REQUEST_SERVER_VERSION, + body = body, + ) +} + +/// Build an HTTP client tuned for EWS: HTTPS only (per `validate_caldav_url`), +/// configurable timeout, no automatic redirect (Autodiscover redirects are +/// followed manually so we can validate them). +pub fn http_client(timeout: Duration) -> Result { + Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() + .context("failed to build HTTP client") +} + +/// Send a SOAP request and return the response body. The caller passes the +/// inner SOAP body; this function adds the envelope, headers, and basic auth. +pub async fn post_soap( + endpoint: &str, + username: &str, + password: &str, + body: &str, + fetch: bool, +) -> Result { + let timeout = if fetch { + FETCH_TIMEOUT + } else { + DEFAULT_TIMEOUT + }; + let client = http_client(timeout)?; + let envelope = envelope(body); + + tracing::debug!(endpoint = %endpoint, body_len = envelope.len(), "EWS SOAP request"); + let resp = client + .post(endpoint) + .basic_auth(username, Some(password)) + .header("Content-Type", "text/xml; charset=utf-8") + .header("Accept", "text/xml") + .body(envelope) + .send() + .await + .with_context(|| format!("EWS request to {endpoint} failed"))?; + + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + tracing::debug!(endpoint = %endpoint, status = %status, body_len = text.len(), "EWS SOAP response"); + + // EWS returns SOAP Faults inside a 200 envelope or 500 with a fault body. + // Tolerate 500 if the body is parseable as a fault — `extract_soap_fault` + // surfaces a descriptive error. + if !status.is_success() && status != StatusCode::INTERNAL_SERVER_ERROR { + if status == StatusCode::UNAUTHORIZED { + bail!("EWS authentication failed (401). Check username/password."); + } + bail!("EWS request returned {status} for {endpoint}"); + } + + if let Some(fault) = extract_soap_fault(&text) { + bail!("EWS SOAP fault: {fault}"); + } + + Ok(text) +} + +/// Pull a human-readable error out of a SOAP fault response. +pub fn extract_soap_fault(xml: &str) -> Option { + if !xml.contains("Fault") && !xml.contains("ResponseCode") { + return None; + } + // Errors come either as a SOAP Fault or as a per-message ResponseCode. + if let Some(reason) = + first_tag_content(xml, "faultstring").or_else(|| first_tag_content(xml, "Reason")) + { + return Some(reason); + } + // Per-message error: ResponseCode != "NoError" with optional MessageText. + let response_code = first_tag_content(xml, "ResponseCode"); + if let Some(code) = &response_code { + if code == "NoError" { + return None; + } + let detail = first_tag_content(xml, "MessageText").unwrap_or_default(); + return Some(if detail.is_empty() { + code.clone() + } else { + format!("{code}: {detail}") + }); + } + None +} + +/// Best-effort scan for the first occurrence of an XML tag's content, +/// regardless of namespace prefix. Mirrors the helpers in `caldav::mod` and is +/// good enough for SOAP responses, which are deterministic in shape. +pub fn first_tag_content(xml: &str, local_name: &str) -> Option { + // Case 1: explicit prefix `...` + let needle_prefixed = format!(":{local_name}"); + let close_marker = format!("/{local_name}>"); + + let mut search_from = 0; + while let Some(pos) = xml[search_from..].find(&needle_prefixed) { + let abs = search_from + pos; + // Walk back to '<' to confirm this is a tag opener. + let before = &xml[..abs]; + if let Some(lt) = before.rfind('<') { + let prefix_part = &xml[lt + 1..abs]; + if !prefix_part.is_empty() + && prefix_part.len() <= 16 + && prefix_part.chars().all(|c| c.is_alphanumeric() || c == '_') + { + let open_tag_end = abs + needle_prefixed.len(); + if let Some(after) = xml[open_tag_end..].find('>') { + let content_start = open_tag_end + after + 1; + let close_full = format!(""); + if let Some(end_rel) = xml[content_start..].find(&close_full) { + let value = xml[content_start..content_start + end_rel].trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + } + } + search_from = abs + needle_prefixed.len(); + } + + // Case 2: unprefixed tag `...` (rare in SOAP but cheap to + // try as a fallback). + let open = format!("<{local_name}"); + if let Some(pos) = xml.find(&open) { + let after_open = &xml[pos + open.len()..]; + let next_byte = after_open.as_bytes().first().copied(); + if next_byte == Some(b'>') || next_byte == Some(b' ') || next_byte == Some(b'/') { + if let Some(close_at) = xml[pos + open.len()..].find('>') { + let content_start = pos + open.len() + close_at + 1; + let close_full = format!(""); + if let Some(end_rel) = xml[content_start..].find(&close_full) { + let value = xml[content_start..content_start + end_rel].trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + // Self-closing tag: skip + let _ = close_marker; + } + } + } + None +} + +/// Find every occurrence of a tag's content (any namespace prefix). +pub fn collect_tag_contents(xml: &str, local_name: &str) -> Vec { + let mut out = Vec::new(); + let needle = format!(":{local_name}"); + let mut search_from = 0; + while let Some(pos) = xml[search_from..].find(&needle) { + let abs = search_from + pos; + let before = &xml[..abs]; + if let Some(lt) = before.rfind('<') { + let prefix_part = &xml[lt + 1..abs]; + if !prefix_part.is_empty() + && prefix_part.len() <= 16 + && prefix_part.chars().all(|c| c.is_alphanumeric() || c == '_') + { + let open_tag_end = abs + needle.len(); + if let Some(after) = xml[open_tag_end..].find('>') { + let content_start = open_tag_end + after + 1; + let close_full = format!(""); + if let Some(end_rel) = xml[content_start..].find(&close_full) { + let value = xml[content_start..content_start + end_rel].trim(); + if !value.is_empty() { + out.push(value.to_string()); + } + search_from = content_start + end_rel + close_full.len(); + continue; + } + } + } + } + search_from = abs + needle.len(); + } + out +} + +/// Find every block bounded by an opening tag (any prefix) and its closing +/// counterpart. Used to slice a multi-item response into per-item windows so +/// downstream parsing operates on a single record at a time. +pub fn collect_blocks(xml: &str, local_name: &str) -> Vec { + let mut out = Vec::new(); + let needle = format!(":{local_name}"); + let mut search_from = 0; + while let Some(pos) = xml[search_from..].find(&needle) { + let abs = search_from + pos; + let before = &xml[..abs]; + let lt = match before.rfind('<') { + Some(idx) => idx, + None => { + search_from = abs + needle.len(); + continue; + } + }; + let prefix_part = &xml[lt + 1..abs]; + if prefix_part.is_empty() + || prefix_part.len() > 16 + || !prefix_part.chars().all(|c| c.is_alphanumeric() || c == '_') + { + search_from = abs + needle.len(); + continue; + } + let open_tag_end = abs + needle.len(); + let after_attrs = match xml[open_tag_end..].find('>') { + Some(a) => open_tag_end + a + 1, + None => break, + }; + let close_full = format!(""); + match xml[after_attrs..].find(&close_full) { + Some(end_rel) => { + let block_end = after_attrs + end_rel; + out.push(xml[after_attrs..block_end].to_string()); + search_from = block_end + close_full.len(); + } + None => break, + } + } + out +} + +/// XML-escape a fragment. Use whenever caller-controlled content (subject, +/// description, calendar id, …) is interpolated into a SOAP body. +pub fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '&' => out.push_str("&"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +/// Inverse of `escape` — XML entities to their literal characters. Used when +/// surfacing values from server responses (e.g. ItemId attributes). +pub fn unescape(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +/// Pull an attribute value out of an XML opening tag fragment. +/// `tag_xml` is something like ``. +pub fn attr(tag_xml: &str, attr_name: &str) -> Option { + let pat = format!("{attr_name}=\""); + let pos = tag_xml.find(&pat)?; + let start = pos + pat.len(); + let end = tag_xml[start..].find('"')?; + Some(tag_xml[start..start + end].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_simple_tag_content() { + let xml = "NoError"; + assert_eq!( + first_tag_content(xml, "ResponseCode"), + Some("NoError".to_string()) + ); + } + + #[test] + fn extract_with_attributes() { + let xml = r#"hello"#; + assert_eq!(first_tag_content(xml, "Body"), Some("hello".to_string())); + } + + #[test] + fn extract_returns_none_for_self_closing() { + let xml = r#""#; + // Self-closing tags have no content, so no match. + assert_eq!(first_tag_content(xml, "ItemId"), None); + } + + #[test] + fn fault_detection() { + let xml = "auth required"; + assert_eq!(extract_soap_fault(xml), Some("auth required".to_string())); + } + + #[test] + fn fault_from_response_code() { + let xml = "ErrorAccessDeniedAccess is denied."; + assert_eq!( + extract_soap_fault(xml), + Some("ErrorAccessDenied: Access is denied.".to_string()) + ); + } + + #[test] + fn fault_no_error_returns_none() { + let xml = "NoError"; + assert_eq!(extract_soap_fault(xml), None); + } + + #[test] + fn collect_blocks_returns_each_record() { + let xml = r#" + One + Two + "#; + let blocks = collect_blocks(xml, "CalendarItem"); + assert_eq!(blocks.len(), 2); + assert!(blocks[0].contains("One")); + assert!(blocks[1].contains("Two")); + } + + #[test] + fn attr_extracts_value() { + let tag = r#""#; + assert_eq!(attr(tag, "Id"), Some("AAAB".to_string())); + assert_eq!(attr(tag, "ChangeKey"), Some("CK1".to_string())); + assert_eq!(attr(tag, "Missing"), None); + } + + #[test] + fn escape_special_chars() { + assert_eq!(escape("a & b < c"), "a & b < c"); + } + + #[test] + fn unescape_roundtrip() { + let original = "a & b < c > d"; + assert_eq!(unescape(&escape(original)), original); + } +} diff --git a/src/main.rs b/src/main.rs index 3319b84..7b37a10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,9 +12,11 @@ mod commands; mod crypto; mod db; mod email; +mod ews; mod i18n; mod models; mod oauth2_caldav; +mod providers; mod rrule; mod utils; mod web; @@ -135,6 +137,14 @@ async fn main() -> Result<()> { Commands::User { command } => commands::user::run(&pool, &data_dir, command).await?, Commands::Config { command } => commands::config::run(&pool, &secret_key, command).await?, Commands::Serve { port, host } => { + let private_hosts = caldav::private_host_allowlist(); + if !private_hosts.is_empty() { + tracing::warn!( + allowed_hosts = ?private_hosts, + "CALRS_ALLOW_PRIVATE_HOSTS is set: listed hostnames bypass the \ + SSRF private-IP guard for CalDAV/EWS URLs" + ); + } // Spawn background reminder task let reminder_pool = pool.clone(); let reminder_key = secret_key; diff --git a/src/providers/caldav.rs b/src/providers/caldav.rs new file mode 100644 index 0000000..e80c917 --- /dev/null +++ b/src/providers/caldav.rs @@ -0,0 +1,116 @@ +//! CalDAV adapter that exposes [`crate::caldav::CaldavClient`] through the +//! generic `CalendarProvider` trait. It is purely a translation layer — all +//! networking and parsing live in `crate::caldav`. + +use anyhow::Result; +use async_trait::async_trait; + +use super::{CalendarProvider, DeltaResult, RawEvent, RemoteCalendar}; +use crate::caldav::CaldavClient; + +pub struct CaldavProvider { + client: CaldavClient, +} + +impl CaldavProvider { + pub fn new(base_url: &str, username: &str, password: &str) -> Self { + Self { + client: CaldavClient::new(base_url, username, password), + } + } + + /// Wrap an already-built `CaldavClient` (e.g. a bearer-authenticated Google + /// CalDAV client) so the rest of the codebase can talk to it through the + /// generic provider trait. + pub fn from_client(client: CaldavClient) -> Self { + Self { client } + } +} + +#[async_trait] +impl CalendarProvider for CaldavProvider { + async fn check_connection(&self) -> Result { + self.client.check_connection().await + } + + async fn list_calendars(&self) -> Result> { + let principal = self.client.discover_principal().await?; + let home = self.client.discover_calendar_home(&principal).await?; + let cals = self.client.list_calendars(&home).await?; + Ok(cals + .into_iter() + .map(|c| RemoteCalendar { + id: c.href, + display_name: c.display_name, + color: c.color, + change_marker: c.ctag, + sync_state: c.sync_token, + }) + .collect()) + } + + async fn fetch_events(&self, calendar_id: &str) -> Result> { + let raws = self.client.fetch_events(calendar_id).await?; + Ok(raws + .into_iter() + .map(|r| RawEvent { + remote_id: r.href, + ical: r.ical_data, + }) + .collect()) + } + + async fn fetch_events_since( + &self, + calendar_id: &str, + since_utc: &str, + ) -> Result> { + let raws = self + .client + .fetch_events_since(calendar_id, since_utc) + .await?; + Ok(raws + .into_iter() + .map(|r| RawEvent { + remote_id: r.href, + ical: r.ical_data, + }) + .collect()) + } + + async fn sync_delta(&self, calendar_id: &str, sync_state: Option<&str>) -> Result { + let result = self.client.sync_collection(calendar_id, sync_state).await?; + // CalDAV reports deletions as 404 hrefs. The href ends with `{uid}.ics`, + // so we extract the UID — the rest of calrs keys events by UID. + let deleted_uids = result + .deleted_hrefs + .iter() + .filter_map(|href| { + href.rsplit('/') + .next() + .map(|s| s.trim_end_matches(".ics").to_string()) + }) + .filter(|s| !s.is_empty()) + .collect(); + Ok(DeltaResult { + added_or_changed: result + .changed + .into_iter() + .map(|r| RawEvent { + remote_id: r.href, + ical: r.ical_data, + }) + .collect(), + deleted_uids, + new_sync_state: result.new_sync_token, + }) + } + + async fn put_event(&self, calendar_id: &str, uid: &str, ics: &str) -> Result<()> { + self.client.put_event(calendar_id, uid, ics).await + } + + async fn delete_event(&self, calendar_id: &str, uid: &str) -> Result<()> { + self.client.delete_event(calendar_id, uid).await + } +} diff --git a/src/providers/factory.rs b/src/providers/factory.rs new file mode 100644 index 0000000..687b8f1 --- /dev/null +++ b/src/providers/factory.rs @@ -0,0 +1,56 @@ +//! Construct a [`CalendarProvider`] from a `caldav_sources` row. +//! +//! Centralising the dispatch here keeps the rest of the codebase ignorant of +//! which protocol a source uses. Add a new back-end by extending the match in +//! `build_provider`. + +use anyhow::{bail, Result}; + +use super::CalendarProvider; + +/// Provider type stored in `caldav_sources.provider_type`. +pub mod kinds { + pub const CALDAV: &str = "caldav"; + pub const EWS: &str = "ews"; +} + +/// Build a provider client for the given source row. +/// +/// `provider_type` is the value stored in `caldav_sources.provider_type`. The +/// other parameters are the URL / username / decrypted password — any of them +/// may carry provider-specific meaning (e.g. for EWS the URL is the +/// `Exchange.asmx` endpoint, for CalDAV it is the discovery URL). +pub fn build_provider( + provider_type: &str, + url: &str, + username: &str, + password: &str, +) -> Result> { + match provider_type { + kinds::CALDAV => Ok(Box::new(super::caldav::CaldavProvider::new( + url, username, password, + ))), + kinds::EWS => Ok(Box::new(crate::ews::EwsProvider::new( + url, username, password, + ))), + other => bail!("Unknown calendar provider type: '{}'", other), + } +} + +/// Validate a URL based on the provider type. CalDAV and EWS both reject +/// non-http(s) and SSRF-prone hostnames. +pub fn validate_url(provider_type: &str, url: &str) -> Result<()> { + match provider_type { + kinds::CALDAV | kinds::EWS => crate::caldav::validate_caldav_url(url), + other => bail!("Unknown calendar provider type: '{}'", other), + } +} + +/// Human-readable label for UI listings. +pub fn label(provider_type: &str) -> &'static str { + match provider_type { + kinds::CALDAV => "CalDAV", + kinds::EWS => "Microsoft Exchange (EWS)", + _ => "Unknown", + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..a6e1046 --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,99 @@ +//! Calendar provider abstraction. +//! +//! `calrs` historically only spoke CalDAV. This module introduces a thin +//! `CalendarProvider` trait so other back-ends (EWS, Microsoft Graph, …) can +//! plug in without touching sync, booking, or write-back code. +//! +//! Each provider hides its own protocol details behind operations expressed in +//! generic terms (a calendar identifier is an opaque string, an event is raw +//! iCalendar text). Sync state and change markers are also opaque, so each +//! provider can use whatever incremental-sync mechanism it has natively +//! (`sync-token` for CalDAV, `SyncState` for EWS). + +use anyhow::Result; +use async_trait::async_trait; + +pub mod caldav; +pub mod factory; + +pub use factory::build_provider; + +/// A calendar discovered on a remote provider. +/// +/// `id` is the opaque identifier the provider uses to address the calendar: +/// for CalDAV it is the HTTP href (e.g. `/calendars/alice/work/`), for EWS it +/// is the folder Id (a long Base64-ish string). Callers must not parse it. +#[derive(Debug, Clone)] +pub struct RemoteCalendar { + pub id: String, + pub display_name: Option, + pub color: Option, + /// Provider-specific change marker for fast skip-if-unchanged. + /// CalDAV: ctag. EWS: not used. + pub change_marker: Option, + /// Sync state cursor for incremental sync. Treat as opaque. + /// CalDAV: WebDAV sync-token (RFC 6578). EWS: SyncFolderItems sync state. + pub sync_state: Option, +} + +/// A raw calendar event as iCalendar (RFC 5545) text plus its remote handle. +/// +/// `remote_id` is whatever the provider uses to address the item: +/// CalDAV: the resource href (`/calendars/alice/work/UID.ics`). +/// EWS: the ItemId. +#[derive(Debug, Clone)] +pub struct RawEvent { + pub remote_id: String, + pub ical: String, +} + +/// Outcome of a delta sync (incremental fetch). +#[derive(Debug, Clone, Default)] +pub struct DeltaResult { + pub added_or_changed: Vec, + /// UIDs of events deleted on the remote server (best-effort: providers that + /// can't surface the iCal UID for deletions return remote ids instead — see + /// the CalDAV adapter for details). + pub deleted_uids: Vec, + pub new_sync_state: Option, +} + +/// Common operations every calendar back-end must support. +/// +/// All methods are best-effort: a provider that genuinely cannot honour an +/// operation (e.g. delta sync on a server that lacks it) should fall back to a +/// sane default (full fetch, empty delta, …) rather than fail loudly. +#[async_trait] +pub trait CalendarProvider: Send + Sync { + /// Verify the provider can be reached and the credentials are accepted. + /// Returns `Ok(true)` when calendar features are explicitly advertised, + /// `Ok(false)` when the connection succeeded but support is uncertain. + async fn check_connection(&self) -> Result; + + /// Discover calendars/folders the authenticated user has access to. + async fn list_calendars(&self) -> Result>; + + /// Fetch every event in a calendar (full snapshot). + async fn fetch_events(&self, calendar_id: &str) -> Result>; + + /// Fetch events with start time at or after `since_utc` (RFC 3339). + /// Implementations that can't filter by time fall back to `fetch_events`. + async fn fetch_events_since(&self, calendar_id: &str, since_utc: &str) + -> Result>; + + /// Incremental sync from a previous sync state. + /// + /// `sync_state = None` means **seed the cursor**: callers use this to + /// obtain a starting sync state after a full fetch, *not* to retrieve + /// items. Implementations may therefore return an empty + /// `added_or_changed` when `sync_state` is `None` — the caller has + /// already populated the local cache through `fetch_events`. + async fn sync_delta(&self, calendar_id: &str, sync_state: Option<&str>) -> Result; + + /// Create-or-replace an event. `uid` is the iCalendar UID, `ics` is the + /// full VCALENDAR/VEVENT block. + async fn put_event(&self, calendar_id: &str, uid: &str, ics: &str) -> Result<()>; + + /// Delete an event by UID. + async fn delete_event(&self, calendar_id: &str, uid: &str) -> Result<()>; +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 26836c5..556a0e1 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -2334,8 +2334,18 @@ async fn dashboard_sources( ) -> impl IntoResponse { let user = &auth_user.user; - let sources: Vec<(String, String, String, String, Option, bool, Option, String)> = sqlx::query_as( - "SELECT cs.id, cs.name, cs.url, cs.username, cs.last_synced, cs.enabled, cs.write_calendar_href, cs.auth_type + let sources: Vec<( + String, + String, + String, + String, + Option, + bool, + Option, + String, + String, + )> = sqlx::query_as( + "SELECT cs.id, cs.name, cs.url, cs.username, cs.last_synced, cs.enabled, cs.write_calendar_href, cs.auth_type, cs.provider_type FROM caldav_sources cs JOIN accounts a ON a.id = cs.account_id WHERE a.user_id = ? @@ -2362,7 +2372,17 @@ async fn dashboard_sources( let sources_ctx: Vec = sources .iter() .map( - |(id, name, url, username, last_synced, enabled, write_cal, auth_type)| { + |( + id, + name, + url, + username, + last_synced, + enabled, + write_cal, + auth_type, + provider_type, + )| { let cals: Vec = all_calendars .iter() .filter(|(sid, _, _)| sid == id) @@ -2383,6 +2403,8 @@ async fn dashboard_sources( enabled => enabled, write_calendar_href => write_cal.as_deref().unwrap_or(""), auth_type => auth_type, + provider_type => provider_type, + provider_label => crate::providers::factory::label(provider_type), calendars => cals, } }, @@ -5188,6 +5210,9 @@ async fn delete_event_type( struct SourceForm { _csrf: Option, provider: Option, + /// Backend protocol: "caldav" (default) or "ews". + #[serde(default)] + provider_type: Option, name: String, url: String, username: String, @@ -5195,25 +5220,67 @@ struct SourceForm { no_test: Option, } -fn caldav_providers() -> Vec<(&'static str, &'static str, &'static str)> { +/// Resolve and validate the provider type from a form input. Defaults to +/// `caldav` when the field is missing (older clients) and rejects unknown +/// values rather than silently coercing. +fn parse_provider_type(raw: Option<&str>) -> Result { + let value = raw + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("caldav"); + match value { + crate::providers::factory::kinds::CALDAV => Ok("caldav".to_string()), + crate::providers::factory::kinds::EWS => Ok("ews".to_string()), + other => Err(format!("Unknown provider type '{}'", other)), + } +} + +/// Preset list shown in the source-add form. Tuple is +/// `(id, display name, default URL, backend)`, where `backend` is `caldav` +/// or `ews`. The template uses the backend tag to keep the Backend dropdown +/// in sync with the preset choice. +fn caldav_providers() -> Vec<(&'static str, &'static str, &'static str, &'static str)> { vec![ - ("bluemind", "BlueMind", "https://mail.example.com/dav/"), + ( + "bluemind", + "BlueMind", + "https://mail.example.com/dav/", + "caldav", + ), ( "nextcloud", "Nextcloud", "https://cloud.example.com/remote.php/dav", + "caldav", ), ( "fastmail", "Fastmail", "https://caldav.fastmail.com/dav/calendars/user/you@fastmail.com/", + "caldav", + ), + ("icloud", "iCloud", "https://caldav.icloud.com/", "caldav"), + ( + "zimbra", + "Zimbra", + "https://mail.example.com/dav/", + "caldav", ), - ("icloud", "iCloud", "https://caldav.icloud.com/"), - ("zimbra", "Zimbra", "https://mail.example.com/dav/"), - ("sogo", "SOGo", "https://mail.example.com/SOGo/dav/"), - ("radicale", "Radicale", "https://cal.example.com/"), - ("google", "Google Calendar", ""), - ("other", "Other / Generic CalDAV", ""), + ( + "sogo", + "SOGo", + "https://mail.example.com/SOGo/dav/", + "caldav", + ), + ("radicale", "Radicale", "https://cal.example.com/", "caldav"), + ("google", "Google Calendar", "", "caldav"), + ( + "exchange", + "Microsoft Exchange (EWS)", + "https://mail.example.com/EWS/Exchange.asmx", + "ews", + ), + ("other", "Other / Generic CalDAV", "", "caldav"), ] } @@ -5228,7 +5295,7 @@ async fn new_source_form( let providers: Vec = caldav_providers() .iter() - .map(|(id, name, url)| context! { id => id, name => name, url => url }) + .map(|(id, name, url, backend)| context! { id => id, name => name, url => url, backend => backend }) .collect(); let google_configured: bool = sqlx::query_scalar::<_, Option>( @@ -5246,6 +5313,7 @@ async fn new_source_form( tmpl.render(context! { providers => providers, form_provider => "bluemind", + form_provider_type => "caldav", form_name => "", form_url => "https://mail.example.com/dav/", form_username => "", @@ -5299,17 +5367,33 @@ async fn create_source( .into_response(); } - // Validate URL against SSRF - if let Err(e) = crate::caldav::validate_caldav_url(&url) { + let provider_type = match parse_provider_type(form.provider_type.as_deref()) { + Ok(p) => p, + Err(msg) => { + return render_source_form_error(&state, &auth_user, &msg, &form).into_response(); + } + }; + + // Validate URL against SSRF (HTTPS-only, no private targets) for both + // CalDAV and EWS — the validator is shared. + if let Err(e) = crate::providers::factory::validate_url(&provider_type, &url) { return render_source_form_error(&state, &auth_user, &e.to_string(), &form).into_response(); } // Test connection unless skip requested let skip_test = form.no_test.as_deref() == Some("on"); if !skip_test { - let client = crate::caldav::CaldavClient::new(&url, &username, &form.password); + let client = + match crate::providers::build_provider(&provider_type, &url, &username, &form.password) + { + Ok(c) => c, + Err(e) => { + return render_source_form_error(&state, &auth_user, &e.to_string(), &form) + .into_response(); + } + }; match client.check_connection().await { - Ok(_) => {} // fine, even if CalDAV not explicitly detected + Ok(_) => {} // fine, even if features not explicitly advertised Err(e) => { let msg = format!("Connection failed: {}. Check the URL and credentials, or check \"Skip connection test\" to save anyway.", e); return render_source_form_error(&state, &auth_user, &msg, &form).into_response(); @@ -5324,7 +5408,7 @@ async fn create_source( }; let _ = sqlx::query( - "INSERT INTO caldav_sources (id, account_id, name, url, username, password_enc) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO caldav_sources (id, account_id, name, url, username, password_enc, provider_type) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&account_id) @@ -5332,10 +5416,11 @@ async fn create_source( .bind(&url) .bind(&username) .bind(&password_enc) + .bind(&provider_type) .execute(&state.pool) .await; - tracing::info!(source_name = %name, user = %auth_user.user.email, "CalDAV source added"); + tracing::info!(source_name = %name, provider = %provider_type, user = %auth_user.user.email, "calendar source added"); // Auto-sync immediately after creating the source, then redirect to // write-back setup if calendars were found. @@ -5343,6 +5428,7 @@ async fn create_source( &state.pool, &state.secret_key, &id, + &provider_type, &url, &username, &form.password, @@ -5375,7 +5461,7 @@ fn render_source_form_error( let providers: Vec = caldav_providers() .iter() - .map(|(id, name, url)| context! { id => id, name => name, url => url }) + .map(|(id, name, url, backend)| context! { id => id, name => name, url => url, backend => backend }) .collect(); let (impersonating, impersonating_name, _) = impersonation_ctx(auth_user); @@ -5383,6 +5469,7 @@ fn render_source_form_error( tmpl.render(context! { providers => providers, form_provider => form.provider.as_deref().unwrap_or("other"), + form_provider_type => form.provider_type.as_deref().unwrap_or("caldav"), form_name => form.name.as_str(), form_url => form.url.as_str(), form_username => form.username.as_str(), @@ -5412,7 +5499,7 @@ fn render_source_edit_form( let providers: Vec = caldav_providers() .iter() - .map(|(id, name, url)| context! { id => id, name => name, url => url }) + .map(|(id, name, url, backend)| context! { id => id, name => name, url => url, backend => backend }) .collect(); let (impersonating, impersonating_name, _) = impersonation_ctx(auth_user); @@ -5627,8 +5714,17 @@ async fn test_source( } let user = &auth_user.user; - let source: Option<(String, String, Option, String, String, Option, Option)> = sqlx::query_as( - "SELECT cs.url, cs.username, cs.password_enc, cs.name, cs.auth_type, cs.access_token_enc, cs.token_expires_at + let source: Option<( + String, + String, + Option, + String, + String, + Option, + Option, + String, + )> = sqlx::query_as( + "SELECT cs.url, cs.username, cs.password_enc, cs.name, cs.auth_type, cs.access_token_enc, cs.token_expires_at, cs.provider_type FROM caldav_sources cs JOIN accounts a ON a.id = cs.account_id WHERE cs.id = ? AND a.user_id = ?", @@ -5639,35 +5735,75 @@ async fn test_source( .await .unwrap_or(None); - let (url, username, password_enc, name, auth_type, access_token_enc, token_expires_at) = - match source { - Some(s) => s, - None => return Html("Source not found.".to_string()).into_response(), - }; - - let client = match crate::oauth2_caldav::build_client_for_source( - &state.pool, - &state.secret_key, - &source_id, - &url, - &auth_type, - &username, - password_enc.as_deref(), - access_token_enc.as_deref(), - token_expires_at.as_deref(), - ) - .await - { - Ok(c) => c, - Err(_) => return Html("Failed to decrypt stored credentials.".to_string()).into_response(), + let ( + url, + username, + password_enc, + name, + auth_type, + access_token_enc, + token_expires_at, + provider_type, + ) = match source { + Some(s) => s, + None => return Html("Source not found.".to_string()).into_response(), }; - let result = match client.check_connection().await { - Ok(true) => format!("'{}' — connection OK, CalDAV supported.", name), - Ok(false) => format!( - "'{}' — connected but CalDAV not explicitly detected. Sync may still work.", - name - ), - Err(e) => format!("'{}' — connection failed: {}", name, e), + + let label = crate::providers::factory::label(&provider_type); + + // EWS sources go through the provider trait; CalDAV (basic or OAuth2) keeps + // the existing CaldavClient path so OAuth2 refresh + ctag stay intact. + let result = if provider_type == crate::providers::factory::kinds::EWS { + let password = match password_enc.as_deref() { + Some(enc) => match crate::crypto::decrypt_password(&state.secret_key, enc) { + Ok(p) => p, + Err(_) => { + return Html("Failed to decrypt stored credentials.".to_string()) + .into_response() + } + }, + None => { + return Html("Source has no stored password.".to_string()).into_response(); + } + }; + match crate::providers::build_provider(&provider_type, &url, &username, &password) { + Ok(client) => match client.check_connection().await { + Ok(true) => format!("'{}' — connection OK ({}).", name, label), + Ok(false) => format!( + "'{}' — connected but {} features not explicitly advertised. Sync may still work.", + name, label, + ), + Err(e) => format!("'{}' — connection failed: {}", name, e), + }, + Err(e) => format!("'{}' — could not build provider: {}", name, e), + } + } else { + let client = match crate::oauth2_caldav::build_client_for_source( + &state.pool, + &state.secret_key, + &source_id, + &url, + &auth_type, + &username, + password_enc.as_deref(), + access_token_enc.as_deref(), + token_expires_at.as_deref(), + ) + .await + { + Ok(c) => c, + Err(_) => { + return Html("Failed to decrypt stored credentials.".to_string()).into_response() + } + }; + match client.check_connection().await { + Ok(true) => format!("'{}' — connection OK, CalDAV supported.", name), + Ok(false) => format!( + "'{}' — connected but CalDAV not explicitly detected. Sync may still work.", + name + ), + Err(e) => format!("'{}' — connection failed: {}", name, e), + } }; // Return a simple page with back link @@ -5705,7 +5841,29 @@ async fn run_sync_for_source( auth_type: &str, access_token_enc: Option<&str>, token_expires_at: Option<&str>, + provider_type: &str, ) -> (Vec, usize) { + // EWS sources go through the provider trait — no OAuth2 dispatch needed. + if provider_type == crate::providers::factory::kinds::EWS { + let enc = match password_enc { + Some(e) => e, + None => return (vec!["EWS source missing password".to_string()], 0), + }; + let password = match crate::crypto::decrypt_password(key, enc) { + Ok(p) => p, + Err(e) => return (vec![format!("Decrypt failed: {}", e)], 0), + }; + return run_sync( + pool, + key, + source_id, + provider_type, + url, + username, + &password, + ) + .await; + } let client = match crate::oauth2_caldav::build_client_for_source( pool, key, @@ -5737,30 +5895,51 @@ async fn run_sync_for_source( } } -/// Runs CalDAV discovery + sync for a source with plaintext password (used during source creation). -/// Returns (messages, calendar_count). +/// Run discovery + sync for a freshly-created source with plaintext password. +/// Dispatches on `provider_type`: EWS goes through the trait-based path, +/// CalDAV reuses the existing `CaldavClient` + `sync_source` flow. async fn run_sync( pool: &SqlitePool, key: &[u8; 32], source_id: &str, + provider_type: &str, url: &str, username: &str, password: &str, ) -> (Vec, usize) { - let client = crate::caldav::CaldavClient::new(url, username, password); - - match crate::commands::sync::sync_source(pool, key, &client, source_id).await { - Ok(()) => { - // Count calendars for this source - let cal_count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM calendars WHERE source_id = ?") - .bind(source_id) - .fetch_one(pool) - .await - .unwrap_or(0); - (vec!["Sync complete.".to_string()], cal_count as usize) + if provider_type == crate::providers::factory::kinds::EWS { + let provider = + match crate::providers::build_provider(provider_type, url, username, password) { + Ok(p) => p, + Err(e) => return (vec![format!("Could not build provider: {}", e)], 0), + }; + match crate::commands::sync::sync_ews_source(pool, key, provider.as_ref(), source_id).await + { + Ok(()) => { + let cal_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM calendars WHERE source_id = ?") + .bind(source_id) + .fetch_one(pool) + .await + .unwrap_or(0); + (vec!["Sync complete.".to_string()], cal_count as usize) + } + Err(e) => (vec![format!("Sync failed: {}", e)], 0), + } + } else { + let client = crate::caldav::CaldavClient::new(url, username, password); + match crate::commands::sync::sync_source(pool, key, &client, source_id).await { + Ok(()) => { + let cal_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM calendars WHERE source_id = ?") + .bind(source_id) + .fetch_one(pool) + .await + .unwrap_or(0); + (vec!["Sync complete.".to_string()], cal_count as usize) + } + Err(e) => (vec![format!("Sync failed: {}", e)], 0), } - Err(e) => (vec![format!("Sync failed: {}", e)], 0), } } @@ -5777,8 +5956,17 @@ async fn force_sync_source( let user = &auth_user.user; // Verify ownership - let source: Option<(String, String, String, Option, String, Option, Option)> = sqlx::query_as( - "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.auth_type, cs.access_token_enc, cs.token_expires_at + let source: Option<( + String, + String, + String, + Option, + String, + Option, + Option, + String, + )> = sqlx::query_as( + "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.auth_type, cs.access_token_enc, cs.token_expires_at, cs.provider_type FROM caldav_sources cs JOIN accounts a ON a.id = cs.account_id WHERE cs.id = ? AND a.user_id = ?", ) @@ -5788,11 +5976,19 @@ async fn force_sync_source( .await .unwrap_or(None); - let (sid, url, username, password_enc, auth_type, access_token_enc, token_expires_at) = - match source { - Some(s) => s, - None => return Html("Source not found.".to_string()).into_response(), - }; + let ( + sid, + url, + username, + password_enc, + auth_type, + access_token_enc, + token_expires_at, + provider_type, + ) = match source { + Some(s) => s, + None => return Html("Source not found.".to_string()).into_response(), + }; // Clear sync tokens to force a full fetch (same as `calrs sync --full`) let _ = sqlx::query("UPDATE calendars SET sync_token = NULL, ctag = NULL WHERE source_id = ?") @@ -5818,6 +6014,7 @@ async fn force_sync_source( &auth_type, access_token_enc.as_deref(), token_expires_at.as_deref(), + &provider_type, ) .await; @@ -5836,8 +6033,18 @@ async fn sync_source( } let user = &auth_user.user; - let source: Option<(String, String, String, Option, String, String, Option, Option)> = sqlx::query_as( - "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.name, cs.auth_type, cs.access_token_enc, cs.token_expires_at + let source: Option<( + String, + String, + String, + Option, + String, + String, + Option, + Option, + String, + )> = sqlx::query_as( + "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.name, cs.auth_type, cs.access_token_enc, cs.token_expires_at, cs.provider_type FROM caldav_sources cs JOIN accounts a ON a.id = cs.account_id WHERE cs.id = ? AND a.user_id = ?", @@ -5848,13 +6055,22 @@ async fn sync_source( .await .unwrap_or(None); - let (sid, url, username, password_enc, name, auth_type, access_token_enc, token_expires_at) = - match source { - Some(s) => s, - None => return Html("Source not found.".to_string()).into_response(), - }; + let ( + sid, + url, + username, + password_enc, + name, + auth_type, + access_token_enc, + token_expires_at, + provider_type, + ) = match source { + Some(s) => s, + None => return Html("Source not found.".to_string()).into_response(), + }; - tracing::info!(source_id = %sid, "CalDAV sync triggered from dashboard"); + tracing::info!(source_id = %sid, "calendar sync triggered from dashboard"); let (messages, calendar_count) = run_sync_for_source( &state.pool, @@ -5866,6 +6082,7 @@ async fn sync_source( &auth_type, access_token_enc.as_deref(), token_expires_at.as_deref(), + &provider_type, ) .await; @@ -13736,6 +13953,7 @@ async fn google_callback( "oauth2", Some(&access_token_enc), Some(&expires_at.to_rfc3339()), + crate::providers::factory::kinds::CALDAV, ) .await; @@ -15768,9 +15986,19 @@ async fn caldav_push_booking( booking_uid: &str, details: &crate::email::BookingDetails, ) { - // Find all CalDAV sources with write_calendar_href configured for this user - let sources: Vec<(String, String, String, Option, String, String, Option, Option)> = sqlx::query_as( - "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.write_calendar_href, cs.auth_type, cs.access_token_enc, cs.token_expires_at + // Find all sources with write_calendar_href configured for this user + let sources: Vec<( + String, + String, + String, + Option, + String, + String, + Option, + Option, + String, + )> = sqlx::query_as( + "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.write_calendar_href, cs.auth_type, cs.access_token_enc, cs.token_expires_at, cs.provider_type FROM caldav_sources cs JOIN accounts a ON a.id = cs.account_id WHERE a.user_id = ? AND cs.enabled = 1 AND cs.write_calendar_href IS NOT NULL", @@ -15798,10 +16026,10 @@ async fn caldav_push_booking( user_id = %user_id, uid = %booking_uid, unconfigured_sources = unconfigured, - "CalDAV write-back skipped: booking confirmed but no source has a write calendar selected. Pick one at /dashboard/sources", + "calendar write-back skipped: booking confirmed but no source has a write calendar selected. Pick one at /dashboard/sources", ); } else { - tracing::debug!(user_id = %user_id, "CalDAV write-back skipped: no enabled CalDAV sources for user"); + tracing::debug!(user_id = %user_id, "calendar write-back skipped: no enabled sources for user"); } return; } @@ -15817,36 +16045,68 @@ async fn caldav_push_booking( auth_type, access_token_enc, token_expires_at, + provider_type, ) in &sources { - let client = match crate::oauth2_caldav::build_client_for_source( - pool, - key, - source_id, - url, - auth_type, - username, - password_enc.as_deref(), - access_token_enc.as_deref(), - token_expires_at.as_deref(), - ) - .await - { - Ok(c) => c, - Err(e) => { - tracing::error!(url = %url, error = %e, "CalDAV write-back failed: could not build client"); - continue; - } - }; + tracing::debug!(uid = %booking_uid, calendar_href = %calendar_href, provider = %provider_type, "pushing booking to calendar"); - tracing::debug!(uid = %booking_uid, calendar_href = %calendar_href, "pushing booking to CalDAV"); + let put_result = if provider_type == crate::providers::factory::kinds::EWS { + let enc = match password_enc.as_deref() { + Some(e) => e, + None => { + tracing::error!(url = %url, "calendar write-back failed: EWS source missing password"); + continue; + } + }; + let password = match crate::crypto::decrypt_password(key, enc) { + Ok(p) => p, + Err(e) => { + tracing::error!(url = %url, error = %e, "calendar write-back failed: could not decrypt credentials"); + continue; + } + }; + let client = match crate::providers::build_provider( + provider_type, + url, + username, + &password, + ) { + Ok(c) => c, + Err(e) => { + tracing::error!(url = %url, error = %e, "calendar write-back failed: unknown provider"); + continue; + } + }; + client.put_event(calendar_href, booking_uid, &ics).await + } else { + let client = match crate::oauth2_caldav::build_client_for_source( + pool, + key, + source_id, + url, + auth_type, + username, + password_enc.as_deref(), + access_token_enc.as_deref(), + token_expires_at.as_deref(), + ) + .await + { + Ok(c) => c, + Err(e) => { + tracing::error!(url = %url, error = %e, "calendar write-back failed: could not build client"); + continue; + } + }; + client.put_event(calendar_href, booking_uid, &ics).await + }; - if let Err(e) = client.put_event(calendar_href, booking_uid, &ics).await { - tracing::error!(uid = %booking_uid, calendar_href = %calendar_href, error = %e, "CalDAV write-back failed"); + if let Err(e) = put_result { + tracing::error!(uid = %booking_uid, calendar_href = %calendar_href, error = %e, "calendar write-back failed"); continue; } - tracing::info!(uid = %booking_uid, calendar_href = %calendar_href, "CalDAV write-back succeeded"); + tracing::info!(uid = %booking_uid, calendar_href = %calendar_href, "calendar write-back succeeded"); // Record which calendar href the booking was pushed to (last successful one) let _ = sqlx::query("UPDATE bookings SET caldav_calendar_href = ? WHERE uid = ?") @@ -15865,8 +16125,18 @@ async fn caldav_delete_for_user( user_id: &str, booking_uid: &str, ) { - let sources: Vec<(String, String, String, Option, String, String, Option, Option)> = sqlx::query_as( - "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.write_calendar_href, cs.auth_type, cs.access_token_enc, cs.token_expires_at + let sources: Vec<( + String, + String, + String, + Option, + String, + String, + Option, + Option, + String, + )> = sqlx::query_as( + "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.write_calendar_href, cs.auth_type, cs.access_token_enc, cs.token_expires_at, cs.provider_type FROM caldav_sources cs JOIN accounts a ON a.id = cs.account_id WHERE a.user_id = ? AND cs.enabled = 1 AND cs.write_calendar_href IS NOT NULL", @@ -15885,26 +16155,45 @@ async fn caldav_delete_for_user( auth_type, access_token_enc, token_expires_at, + provider_type, ) in &sources { - let client = match crate::oauth2_caldav::build_client_for_source( - pool, - key, - source_id, - url, - auth_type, - username, - password_enc.as_deref(), - access_token_enc.as_deref(), - token_expires_at.as_deref(), - ) - .await - { - Ok(c) => c, - Err(_) => continue, + let delete_result = if provider_type == crate::providers::factory::kinds::EWS { + let enc = match password_enc.as_deref() { + Some(e) => e, + None => continue, + }; + let password = match crate::crypto::decrypt_password(key, enc) { + Ok(p) => p, + Err(_) => continue, + }; + let client = + match crate::providers::build_provider(provider_type, url, username, &password) { + Ok(c) => c, + Err(_) => continue, + }; + client.delete_event(calendar_href, booking_uid).await + } else { + let client = match crate::oauth2_caldav::build_client_for_source( + pool, + key, + source_id, + url, + auth_type, + username, + password_enc.as_deref(), + access_token_enc.as_deref(), + token_expires_at.as_deref(), + ) + .await + { + Ok(c) => c, + Err(_) => continue, + }; + client.delete_event(calendar_href, booking_uid).await }; - if let Err(e) = client.delete_event(calendar_href, booking_uid).await { - tracing::error!(uid = %booking_uid, user = %user_id, calendar = %calendar_href, error = %e, "CalDAV event delete failed"); + if let Err(e) = delete_result { + tracing::error!(uid = %booking_uid, user = %user_id, calendar = %calendar_href, error = %e, "calendar event delete failed"); } } @@ -15944,9 +16233,18 @@ async fn caldav_delete_booking( None => return, // Was never pushed to CalDAV }; - // Get the CalDAV source credentials - let source: Option<(String, String, String, Option, String, Option, Option)> = sqlx::query_as( - "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.auth_type, cs.access_token_enc, cs.token_expires_at + // Get the source credentials and provider type + let source: Option<( + String, + String, + String, + Option, + String, + Option, + Option, + String, + )> = sqlx::query_as( + "SELECT cs.id, cs.url, cs.username, cs.password_enc, cs.auth_type, cs.access_token_enc, cs.token_expires_at, cs.provider_type FROM caldav_sources cs JOIN accounts a ON a.id = cs.account_id WHERE a.user_id = ? AND cs.enabled = 1 AND cs.write_calendar_href = ? @@ -15958,30 +16256,56 @@ async fn caldav_delete_booking( .await .unwrap_or(None); - let (source_id, url, username, password_enc, auth_type, access_token_enc, token_expires_at) = - match source { - Some(s) => s, + let ( + source_id, + url, + username, + password_enc, + auth_type, + access_token_enc, + token_expires_at, + provider_type, + ) = match source { + Some(s) => s, + None => return, + }; + + let delete_result = if provider_type == crate::providers::factory::kinds::EWS { + let enc = match password_enc.as_deref() { + Some(e) => e, None => return, }; - - let client = match crate::oauth2_caldav::build_client_for_source( - pool, - key, - &source_id, - &url, - &auth_type, - &username, - password_enc.as_deref(), - access_token_enc.as_deref(), - token_expires_at.as_deref(), - ) - .await - { - Ok(c) => c, - Err(_) => return, + let password = match crate::crypto::decrypt_password(key, enc) { + Ok(p) => p, + Err(_) => return, + }; + let client = + match crate::providers::build_provider(&provider_type, &url, &username, &password) { + Ok(c) => c, + Err(_) => return, + }; + client.delete_event(&calendar_href, booking_uid).await + } else { + let client = match crate::oauth2_caldav::build_client_for_source( + pool, + key, + &source_id, + &url, + &auth_type, + &username, + password_enc.as_deref(), + access_token_enc.as_deref(), + token_expires_at.as_deref(), + ) + .await + { + Ok(c) => c, + Err(_) => return, + }; + client.delete_event(&calendar_href, booking_uid).await }; - if let Err(e) = client.delete_event(&calendar_href, booking_uid).await { - tracing::error!(uid = %booking_uid, error = %e, "CalDAV event delete failed"); + if let Err(e) = delete_result { + tracing::error!(uid = %booking_uid, error = %e, "calendar event delete failed"); } // Also remove the cached event from local DB so it doesn't block availability diff --git a/templates/dashboard_sources.html b/templates/dashboard_sources.html index 8b73614..411cb7c 100644 --- a/templates/dashboard_sources.html +++ b/templates/dashboard_sources.html @@ -12,6 +12,9 @@

Calendar sources

{{ s.name }} + {% if s.provider_label %} + {{ s.provider_label }} + {% endif %}
{{ s.url }}
{% if s.auth_type == "oauth2" %}OAuth2 {{ s.username }}{% else %}{{ s.username }}{% endif %} · Last sync: {{ s.last_synced }}
diff --git a/templates/source_form.html b/templates/source_form.html index ef194af..41db98d 100644 --- a/templates/source_form.html +++ b/templates/source_form.html @@ -5,8 +5,8 @@ {% block dashboard_content %}
-

{% if editing %}Edit calendar source{% else %}Connect a CalDAV calendar{% endif %}

-

{% if editing %}Update the connection. Leave the password blank to keep the existing one. After changing the URL or username, run a sync to refresh the discovered calendar list.{% else %}Connect your calendar server to check availability when guests book meetings.{% endif %}

+

{% if editing %}Edit calendar source{% else %}Connect a calendar{% endif %}

+

{% if editing %}Update the connection. Leave the password blank to keep the existing one. After changing the URL or username, run a sync to refresh the discovered calendar list.{% else %}Connect a CalDAV server or Microsoft Exchange (EWS) so calrs can check availability when guests book meetings.{% endif %}

{% if error %}
{{ error }}
@@ -14,10 +14,21 @@

{% if editing %}Edit calendar source{% else %}Connect a CalDAV calendar{% en
- + + + + Pick the protocol your server speaks. EWS targets on-prem Exchange 2019/2016/2013. + +
+ +
+
@@ -62,6 +73,12 @@

{% if editing %}Edit calendar source{% else %}Connect a CalDAV calendar{% en Google Calendar integration is not configured yet. Ask your administrator to set up Google OAuth2 credentials in the admin panel. {% endif %}

+
- +
@@ -116,13 +133,58 @@

{% if editing %}Edit calendar source{% else %}Connect a CalDAV calendar{% en