Skip to content

Commit 8b5cbce

Browse files
huntervcxArthur Perrotclaude
authored
feat(ews): add Microsoft Exchange (EWS) calendar backend (olivierlambert#103)
* feat(providers): add Microsoft Exchange (EWS) calendar provider Introduces a generic CalendarProvider trait so calrs is no longer hard-wired to CalDAV. Adds an EWS implementation targeting on-prem Exchange 2019 (also compatible with 2016/2013) with Autodiscover, calendar listing, full + windowed event fetch, create/delete, and SyncFolderItems delta sync. The CalDAV path is preserved unchanged behind a thin adapter; sync, source management, and write-back now dispatch on a new provider_type column (migration 050). HTTPS-only and SSRF-safe URL validation is shared across both providers, and Autodiscover candidate URLs are re-validated before they get hit. Layout: src/providers/ trait + factory + caldav adapter src/ews/ soap, autodiscover, operations, parse, ical synth Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fixup(ews): drop unused Context import + dead summary_view, collapse duplicate connection-check arms, rustfmt Cargo.lock is updated by Cargo's first compile after a fresh dependency add (async-trait); committing it so a clean checkout doesn't churn it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip: save ews provider work * feat: clean sources presets filtered via backend * fixup(ews): address review blockers + substantive items Blockers (from review): - Migration 050_provider_type renumbered to 054_provider_type to avoid collision with upstream main's 051-053. src/db.rs migration array + CLAUDE.md index updated. - Migration-count assertions in db.rs tests bumped from 50 to 54. Substantive review items folded in: - sync_folder_items now caps at 200 SOAP iterations; a server that never sets IncludesLastItemInRange=true returns partial results and is resumed from the latest cursor next sync. (olivierlambert#8) - synth_vcalendar emits DTSTAMP per RFC 5545; EWS doesn't expose a stable last-modified, so we use Utc::now(). (olivierlambert#6) - EWS sync_delta comment rewritten to acknowledge "EWS sources rely on full fetches" rather than claiming the cursor gets bootstrapped. (olivierlambert#3, option A) - fetch_events_since wired into sync_ews_source with the same 90-day lookback used by the CalDAV path. The EWS impl uses CalendarView (server-side window). remove_orphaned_ews_events now takes the same since_prefix scoping as the CalDAV variant so events outside the window are not flagged as orphans. (olivierlambert#4) - format_dt TODO documenting naive-local TZID drift on non-recurring items. (olivierlambert#7) - Autodiscover redirect policy now carries an explicit comment about the SSRF residual risk on intermediate Location headers. (olivierlambert#5) - 054_provider_type.sql explains the calendars.href / calendars.ctag reuse for EWS folder ItemId / ChangeKey. (olivierlambert#9) - extract_vcalendar comment corrected: the \r\n /\r\n\t replacements are RFC 5545 §3.1 line-fold unfolding, not "stray indentation". Rebase side-effects: - SSRF guard now uses upstream's CALRS_ALLOW_PRIVATE_HOSTS allowlist (hostname-scoped) instead of the previous boolean toggle. private_ host_allowlist() is exposed for the serve startup WARN log. - Sources flow (commands/source.rs, commands/sync.rs, web/mod.rs) reconciled with upstream's OAuth2 dispatch + orphan-cancel work: EWS sources go through the provider trait, CalDAV sources keep the CaldavClient path (basic-auth or OAuth2 unchanged). - new sync_ews_source/upsert_calendar_provider/upsert_provider_events helpers in commands/sync.rs to keep the EWS path off the CalDAV CaldavClient signature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Arthur Perrot <aperrot@dyb.fr> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 75d71aa commit 8b5cbce

21 files changed

Lines changed: 3280 additions & 219 deletions

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
| CLI | `clap` v4 (derive API) | Subcommand tree pattern |
1919
| Async runtime | `tokio` (full features) | Used throughout |
2020
| Database | SQLite via `sqlx` 0.7 | WAL mode, foreign keys enabled, migrations inlined |
21-
| HTTP client | `reqwest` (rustls, no openssl) | CalDAV PROPFIND/REPORT requests |
21+
| HTTP client | `reqwest` (rustls, no openssl) | CalDAV PROPFIND/REPORT and EWS SOAP requests |
22+
| Calendar providers | trait `CalendarProvider` (`src/providers/`) | Pluggable back-ends: CalDAV, EWS (Exchange 2019). Sync/source code dispatches via the trait. |
23+
| Async traits | `async-trait` 0.1 | Object-safe `dyn CalendarProvider`. |
2224
| XML parsing | `quick-xml` 0.31 | CalDAV responses are XML over WebDAV |
2325
| iCal | `icalendar` crate | Parsing/generating VEVENT data |
2426
| Time | `chrono` + `chrono-tz` | Timezone handling is a known complexity area |
@@ -90,7 +92,8 @@ calrs/
9092
│ ├── 041_last_full_sync.sql ← last_full_sync timestamp on caldav_sources
9193
│ ├── 042_event_transp.sql ← TRANSP column on events (skip TRANSPARENT)
9294
│ ├── 043_event_type_watchers.sql ← event_type_watchers junction (team watches event type)
93-
│ └── 044_booking_claim.sql ← claimed_by_user_id/claimed_at on bookings + booking_claim_tokens
95+
│ ├── 044_booking_claim.sql ← claimed_by_user_id/claimed_at on bookings + booking_claim_tokens
96+
│ └── 055_provider_type.sql ← provider_type on caldav_sources (caldav/ews) for the calendar-provider abstraction
9497
├── templates/
9598
│ ├── base.html ← base layout + CSS (light/dark mode)
9699
│ ├── dashboard_base.html ← sidebar layout (extends base.html, all dashboard pages extend this)

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ clap = { version = "4", features = ["derive", "env"] }
1616

1717
# Async runtime
1818
tokio = { version = "1", features = ["full"] }
19+
async-trait = "0.1"
1920

2021
# Database
2122
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono", "uuid"] }

migrations/055_provider_type.sql

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Add provider_type column to caldav_sources so a single sources table can
2+
-- describe multiple back-end protocols (CalDAV, EWS, ...). Existing rows keep
3+
-- the historical default of 'caldav'.
4+
--
5+
-- Schema reuse note: the `calendars` table keeps its CalDAV-era column names
6+
-- when populated by an EWS source. Specifically:
7+
-- * `calendars.href` holds the EWS folder ItemId (opaque base64-ish blob).
8+
-- * `calendars.ctag` holds the EWS ChangeKey, if any.
9+
-- The semantics are identical (opaque change marker / opaque resource id)
10+
-- and the rest of the codebase treats them that way via the provider trait
11+
-- (`crate::providers::RemoteCalendar`). Renaming the columns would force a
12+
-- table-rebuild migration on existing CalDAV deployments for no behavioural
13+
-- gain, so we accept the misleading names and surface them here.
14+
ALTER TABLE caldav_sources ADD COLUMN provider_type TEXT NOT NULL DEFAULT 'caldav';
15+
CREATE INDEX IF NOT EXISTS idx_caldav_sources_provider_type ON caldav_sources(provider_type);

src/caldav/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ fn is_private_ip(ip: &IpAddr) -> bool {
4545
/// Parse the `CALRS_ALLOW_PRIVATE_HOSTS` env var into a list of hostnames that
4646
/// are permitted to resolve to private/reserved IPs. Comma-separated,
4747
/// whitespace-trimmed, case-insensitive. Empty entries are ignored.
48-
fn private_host_allowlist() -> Vec<String> {
48+
pub fn private_host_allowlist() -> Vec<String> {
4949
std::env::var("CALRS_ALLOW_PRIVATE_HOSTS")
5050
.unwrap_or_default()
5151
.split(',')

src/commands/source.rs

Lines changed: 143 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ use sqlx::SqlitePool;
55
use tabled::{Table, Tabled};
66
use uuid::Uuid;
77

8+
use crate::providers::{build_provider, factory};
9+
810
use std::io::{self, Write};
911
use tokio::io::{AsyncReadExt, AsyncWriteExt};
1012

1113
use crate::utils::prompt;
1214

1315
#[derive(Debug, Subcommand)]
1416
pub enum SourceCommands {
15-
/// Connect a CalDAV calendar
17+
/// Connect a calendar source (CalDAV or Exchange/EWS)
1618
Add {
17-
/// CalDAV server URL
19+
/// Source URL. CalDAV: discovery root. EWS: `Exchange.asmx` endpoint
20+
/// (auto-discovered when omitted with `--provider ews`).
1821
#[arg(long)]
1922
url: Option<String>,
2023
/// Username
@@ -23,6 +26,12 @@ pub enum SourceCommands {
2326
/// Display name for this source
2427
#[arg(long)]
2528
name: Option<String>,
29+
/// Provider type: `caldav` (default) or `ews`.
30+
#[arg(long, default_value = "caldav")]
31+
provider: String,
32+
/// For EWS: email used for Autodiscover when --url is not supplied.
33+
#[arg(long)]
34+
email: Option<String>,
2635
/// Skip the connection test
2736
#[arg(long)]
2837
no_test: bool,
@@ -34,7 +43,7 @@ pub enum SourceCommands {
3443
/// Source ID
3544
id: String,
3645
},
37-
/// Test a CalDAV connection
46+
/// Test a connection
3847
Test {
3948
/// Source ID
4049
id: String,
@@ -70,6 +79,8 @@ struct SourceRow {
7079
id: String,
7180
#[tabled(rename = "Name")]
7281
name: String,
82+
#[tabled(rename = "Type")]
83+
provider: String,
7384
#[tabled(rename = "URL")]
7485
url: String,
7586
#[tabled(rename = "Username")]
@@ -84,31 +95,78 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu
8495
url,
8596
username,
8697
name,
98+
provider,
99+
email,
87100
no_test,
88101
} => {
102+
let provider = provider.trim().to_ascii_lowercase();
103+
if provider != factory::kinds::CALDAV && provider != factory::kinds::EWS {
104+
bail!("unknown provider '{}'. Use 'caldav' or 'ews'.", provider);
105+
}
106+
89107
let account: (String,) = sqlx::query_as("SELECT id FROM accounts LIMIT 1")
90108
.fetch_optional(pool)
91109
.await?
92110
.ok_or_else(|| anyhow::anyhow!("No account found. Run `calrs init` first."))?;
93111

94-
let url = url.unwrap_or_else(|| prompt("CalDAV URL"));
95112
let username = username.unwrap_or_else(|| prompt("Username"));
96113
let name = name.unwrap_or_else(|| prompt("Display name"));
97114
let password = rpassword::prompt_password("Password: ").unwrap_or_default();
98115

99-
// Test connection
116+
// Resolve URL: explicit flag wins; otherwise EWS gets a chance to
117+
// autodiscover from the email; CalDAV always asks the user.
118+
let url = match url {
119+
Some(u) => u,
120+
None => match provider.as_str() {
121+
factory::kinds::EWS => {
122+
let email_for_disco = email
123+
.clone()
124+
.unwrap_or_else(|| prompt("Email (for Autodiscover)"));
125+
print!(
126+
"{} Discovering EWS endpoint via Autodiscover… ",
127+
"…".dimmed()
128+
);
129+
io::stdout().flush().ok();
130+
match crate::ews::autodiscover::discover_ews_url(
131+
&email_for_disco,
132+
&password,
133+
)
134+
.await
135+
{
136+
Ok(u) => {
137+
println!("{}", u.green());
138+
u
139+
}
140+
Err(e) => {
141+
println!("{}", "failed".red());
142+
println!(
143+
" {} Autodiscover failed: {}. Falling back to manual entry.",
144+
"!".yellow(),
145+
e
146+
);
147+
prompt("EWS Exchange.asmx URL")
148+
}
149+
}
150+
}
151+
_ => prompt("CalDAV URL"),
152+
},
153+
};
154+
155+
// Validate URL (HTTPS, no SSRF target).
156+
factory::validate_url(&provider, &url)?;
157+
158+
// Test connection unless skipped
100159
if !no_test {
101160
print!("{} Testing connection… ", "…".dimmed());
102161
io::stdout().flush().unwrap();
103162

104-
let client = crate::caldav::CaldavClient::new(&url, &username, &password);
163+
let client = build_provider(&provider, &url, &username, &password)?;
105164
match client.check_connection().await {
106-
Ok(true) => println!("{}", "CalDAV supported".green()),
165+
Ok(true) => println!("{}", "OK".green()),
107166
Ok(false) => {
108167
println!(
109168
"{}",
110-
"No CalDAV support detected (missing calendar-access in DAV header)"
111-
.yellow()
169+
"Connected, but provider features not explicitly advertised".yellow()
112170
);
113171
println!("Continuing anyway…");
114172
}
@@ -123,25 +181,34 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu
123181
let password_enc = crate::crypto::encrypt_password(key, &password)?;
124182

125183
sqlx::query(
126-
"INSERT INTO caldav_sources (id, account_id, name, url, username, password_enc) VALUES (?, ?, ?, ?, ?, ?)",
184+
"INSERT INTO caldav_sources (id, account_id, name, url, username, password_enc, provider_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
127185
)
128186
.bind(&id)
129187
.bind(&account.0)
130188
.bind(&name)
131189
.bind(&url)
132190
.bind(&username)
133191
.bind(&password_enc)
192+
.bind(&provider)
134193
.execute(pool)
135194
.await?;
136195

137-
println!("{} Source '{}' added (id: {})", "✓".green(), name, &id[..8]);
196+
println!(
197+
"{} Source '{}' ({}) added (id: {})",
198+
"✓".green(),
199+
name,
200+
factory::label(&provider),
201+
&id[..8]
202+
);
138203
}
139204
SourceCommands::List => {
140-
let sources: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
141-
"SELECT id, name, url, username, last_synced FROM caldav_sources ORDER BY created_at",
142-
)
143-
.fetch_all(pool)
144-
.await?;
205+
let sources: Vec<(String, String, String, String, Option<String>, String)> =
206+
sqlx::query_as(
207+
"SELECT id, name, url, username, last_synced, provider_type
208+
FROM caldav_sources ORDER BY created_at",
209+
)
210+
.fetch_all(pool)
211+
.await?;
145212

146213
if sources.is_empty() {
147214
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
150217

151218
let rows: Vec<SourceRow> = sources
152219
.into_iter()
153-
.map(|(id, name, url, username, last_synced)| SourceRow {
154-
id: id[..8].to_string(),
155-
name,
156-
url,
157-
username,
158-
last_synced: last_synced.unwrap_or_else(|| "never".to_string()),
159-
})
220+
.map(
221+
|(id, name, url, username, last_synced, provider_type)| SourceRow {
222+
id: id[..8].to_string(),
223+
name,
224+
provider: factory::label(&provider_type).to_string(),
225+
url,
226+
username,
227+
last_synced: last_synced.unwrap_or_else(|| "never".to_string()),
228+
},
229+
)
160230
.collect();
161231

162232
println!("{}", Table::new(rows));
@@ -252,8 +322,20 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu
252322
}
253323
}
254324
SourceCommands::Test { id } => {
255-
let source: Option<(String, String, String, String, Option<String>, String, Option<String>, Option<String>)> = sqlx::query_as(
256-
"SELECT id, url, username, name, password_enc, auth_type, access_token_enc, token_expires_at FROM caldav_sources WHERE id LIKE ? || '%'",
325+
let source: Option<(
326+
String,
327+
String,
328+
String,
329+
String,
330+
Option<String>,
331+
String,
332+
Option<String>,
333+
Option<String>,
334+
String,
335+
)> = sqlx::query_as(
336+
"SELECT id, url, username, name, password_enc, auth_type, \
337+
access_token_enc, token_expires_at, provider_type \
338+
FROM caldav_sources WHERE id LIKE ? || '%'",
257339
)
258340
.bind(&id)
259341
.fetch_optional(pool)
@@ -269,23 +351,44 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu
269351
auth_type,
270352
access_token_enc,
271353
token_expires_at,
354+
provider_type,
272355
)) => {
273-
println!("Testing source '{}'…", name);
274-
let client = crate::oauth2_caldav::build_client_for_source(
275-
pool,
276-
key,
277-
&source_id,
278-
&url,
279-
&auth_type,
280-
&username,
281-
password_enc.as_deref(),
282-
access_token_enc.as_deref(),
283-
token_expires_at.as_deref(),
284-
)
285-
.await?;
356+
println!(
357+
"Testing source '{}' ({})…",
358+
name,
359+
factory::label(&provider_type)
360+
);
361+
362+
// OAuth2 sources are CalDAV-only (Google). Basic-auth
363+
// sources may be CalDAV or EWS; let the provider factory
364+
// pick the right back-end.
365+
let client: Box<dyn crate::providers::CalendarProvider> =
366+
if auth_type == "oauth2" {
367+
let caldav = crate::oauth2_caldav::build_client_for_source(
368+
pool,
369+
key,
370+
&source_id,
371+
&url,
372+
&auth_type,
373+
&username,
374+
password_enc.as_deref(),
375+
access_token_enc.as_deref(),
376+
token_expires_at.as_deref(),
377+
)
378+
.await?;
379+
Box::new(crate::providers::caldav::CaldavProvider::from_client(
380+
caldav,
381+
))
382+
} else {
383+
let enc = password_enc.as_deref().ok_or_else(|| {
384+
anyhow::anyhow!("Basic auth source missing password")
385+
})?;
386+
let password = crate::crypto::decrypt_password(key, enc)?;
387+
build_provider(&provider_type, &url, &username, &password)?
388+
};
286389
match client.check_connection().await {
287-
Ok(true) => println!("{} Connection OK — CalDAV supported", "✓".green()),
288-
Ok(false) => println!("{} Connected but CalDAV not detected", "⚠".yellow()),
390+
Ok(true) => println!("{} Connection OK", "✓".green()),
391+
Ok(false) => println!("{} Connected, partial detection", "⚠".yellow()),
289392
Err(e) => println!("{} Connection failed: {}", "✗".red(), e),
290393
}
291394
}

0 commit comments

Comments
 (0)