Skip to content

Commit 04884ec

Browse files
Arthur Perrotclaude
andcommitted
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>
1 parent 8dc87ca commit 04884ec

10 files changed

Lines changed: 170 additions & 106 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ calrs/
9393
│ ├── 042_event_transp.sql ← TRANSP column on events (skip TRANSPARENT)
9494
│ ├── 043_event_type_watchers.sql ← event_type_watchers junction (team watches event type)
9595
│ ├── 044_booking_claim.sql ← claimed_by_user_id/claimed_at on bookings + booking_claim_tokens
96-
│ └── 050_provider_type.sql ← provider_type on caldav_sources (caldav/ews) for the calendar-provider abstraction
96+
│ └── 054_provider_type.sql ← provider_type on caldav_sources (caldav/ews) for the calendar-provider abstraction
9797
├── templates/
9898
│ ├── base.html ← base layout + CSS (light/dark mode)
9999
│ ├── dashboard_base.html ← sidebar layout (extends base.html, all dashboard pages extend this)

migrations/054_provider_type.sql

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
-- Add provider_type column to caldav_sources so a single sources table can
22
-- describe multiple back-end protocols (CalDAV, EWS, ...). Existing rows keep
33
-- 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.
414
ALTER TABLE caldav_sources ADD COLUMN provider_type TEXT NOT NULL DEFAULT 'caldav';
515
CREATE INDEX IF NOT EXISTS idx_caldav_sources_provider_type ON caldav_sources(provider_type);

src/commands/source.rs

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -362,31 +362,30 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu
362362
// OAuth2 sources are CalDAV-only (Google). Basic-auth
363363
// sources may be CalDAV or EWS; let the provider factory
364364
// pick the right back-end.
365-
let client: Box<dyn crate::providers::CalendarProvider> = if auth_type
366-
== "oauth2"
367-
{
368-
let caldav = crate::oauth2_caldav::build_client_for_source(
369-
pool,
370-
key,
371-
&source_id,
372-
&url,
373-
&auth_type,
374-
&username,
375-
password_enc.as_deref(),
376-
access_token_enc.as_deref(),
377-
token_expires_at.as_deref(),
378-
)
379-
.await?;
380-
Box::new(crate::providers::caldav::CaldavProvider::from_client(
381-
caldav,
382-
))
383-
} else {
384-
let enc = password_enc.as_deref().ok_or_else(|| {
385-
anyhow::anyhow!("Basic auth source missing password")
386-
})?;
387-
let password = crate::crypto::decrypt_password(key, enc)?;
388-
build_provider(&provider_type, &url, &username, &password)?
389-
};
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+
};
390389
match client.check_connection().await {
391390
Ok(true) => println!("{} Connection OK", "✓".green()),
392391
Ok(false) => println!("{} Connected, partial detection", "⚠".yellow()),

src/commands/sync.rs

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,14 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], full: bool) -> Result<()> {
7878
// EWS sources go through the provider trait (no OAuth2, no CalDAV-only
7979
// sync-collection); CalDAV sources keep the existing flow.
8080
if provider_type == kinds::EWS {
81-
let password = match crate::crypto::decrypt_password(key, password_enc.as_deref().unwrap_or("")) {
82-
Ok(p) => p,
83-
Err(e) => {
84-
println!(" {} Decrypt failed: {}", "✗".red(), e);
85-
continue;
86-
}
87-
};
81+
let password =
82+
match crate::crypto::decrypt_password(key, password_enc.as_deref().unwrap_or("")) {
83+
Ok(p) => p,
84+
Err(e) => {
85+
println!(" {} Decrypt failed: {}", "✗".red(), e);
86+
continue;
87+
}
88+
};
8889
let provider =
8990
match crate::providers::build_provider(provider_type, url, username, &password) {
9091
Ok(p) => p,
@@ -370,22 +371,16 @@ pub async fn sync_if_stale(pool: &SqlitePool, key: &[u8; 32], user_id: &str) {
370371
}
371372

372373
if provider_type == kinds::EWS {
373-
let password = match crate::crypto::decrypt_password(
374-
key,
375-
password_enc.as_deref().unwrap_or(""),
376-
) {
377-
Ok(p) => p,
378-
Err(_) => continue,
379-
};
380-
let provider = match crate::providers::build_provider(
381-
provider_type,
382-
url,
383-
username,
384-
&password,
385-
) {
386-
Ok(p) => p,
387-
Err(_) => continue,
388-
};
374+
let password =
375+
match crate::crypto::decrypt_password(key, password_enc.as_deref().unwrap_or("")) {
376+
Ok(p) => p,
377+
Err(_) => continue,
378+
};
379+
let provider =
380+
match crate::providers::build_provider(provider_type, url, username, &password) {
381+
Ok(p) => p,
382+
Err(_) => continue,
383+
};
389384
let _ = sync_ews_source(pool, key, provider.as_ref(), source_id).await;
390385
continue;
391386
}
@@ -501,15 +496,25 @@ pub async fn sync_ews_source(
501496
) -> Result<()> {
502497
let calendars = provider.list_calendars().await?;
503498

499+
// Bounded fetch window. Matches the CalDAV path's FULL_FETCH_LOOKBACK_DAYS:
500+
// 90 days back is plenty for orphan reconciliation and keeps EWS response
501+
// sizes predictable. The provider's fetch_events_since uses CalendarView,
502+
// which expands recurrences server-side within the window.
503+
let since_dt = Utc::now() - chrono::Duration::days(FULL_FETCH_LOOKBACK_DAYS);
504+
let since_iso = since_dt.to_rfc3339();
505+
let since_prefix = since_dt.format("%Y%m%d").to_string();
506+
504507
for cal_info in &calendars {
505508
let (cal_id, _stored_change_marker, _stored_sync_state) =
506509
upsert_calendar_provider(pool, source_id, cal_info).await?;
507510
let cal_label = cal_info.display_name.as_deref().unwrap_or(&cal_info.id);
508511

509-
match provider.fetch_events(&cal_info.id).await {
512+
match provider.fetch_events_since(&cal_info.id, &since_iso).await {
510513
Ok(raw_events) => {
511514
let count = upsert_provider_events(pool, &cal_id, &raw_events).await;
512-
let deleted = remove_orphaned_ews_events(pool, key, &cal_id, &raw_events).await;
515+
let deleted =
516+
remove_orphaned_ews_events(pool, key, &cal_id, &raw_events, &since_prefix)
517+
.await;
513518
if deleted > 0 {
514519
tracing::info!(
515520
calendar_name = cal_label,
@@ -656,16 +661,21 @@ async fn upsert_provider_events(
656661
count
657662
}
658663

659-
/// EWS variant of orphan reconciliation: full sweep without a time-range
660-
/// bound (EWS `FindItem` returns everything in the folder by default).
661-
/// `client = None` skips the HTTP confirm-before-cancel verification used in
662-
/// the CalDAV path — EWS double-booking deletes go through the provider
663-
/// trait, and the CalDAV `CaldavClient` is the wrong type here.
664+
/// EWS variant of orphan reconciliation, scoped to the fetched window.
665+
/// `since_prefix` is a `YYYYMMDD` lower bound matching the
666+
/// `fetch_events_since` call: events with `start_at` before it weren't in
667+
/// the response and must not be flagged as orphans. Pass an empty string to
668+
/// reconcile against every local event.
669+
///
670+
/// `client = None` is implied: EWS sources can't be HTTP-verified against a
671+
/// `CaldavClient`, so we go straight to DB cancellation when an event has
672+
/// vanished from the server.
664673
async fn remove_orphaned_ews_events(
665674
pool: &SqlitePool,
666675
key: &[u8; 32],
667676
cal_id: &str,
668677
raw_events: &[ProviderRawEvent],
678+
since_prefix: &str,
669679
) -> u32 {
670680
let mut seen_uids: Vec<(String, String)> = Vec::new();
671681
for raw in raw_events {
@@ -681,10 +691,17 @@ async fn remove_orphaned_ews_events(
681691
return 0;
682692
}
683693

694+
// Same window-scoping trick as the CalDAV path: compact ("YYYYMMDDTHHMMSS")
695+
// and all-day ("YYYYMMDD") start_at values both sort against a YYYYMMDD
696+
// lower bound.
684697
let local_events: Vec<(String, String, Option<String>)> = sqlx::query_as(
685-
"SELECT id, uid, recurrence_id FROM events WHERE calendar_id = ?",
698+
"SELECT id, uid, recurrence_id FROM events
699+
WHERE calendar_id = ?
700+
AND (? = '' OR start_at >= ?)",
686701
)
687702
.bind(cal_id)
703+
.bind(since_prefix)
704+
.bind(since_prefix)
688705
.fetch_all(pool)
689706
.await
690707
.unwrap_or_default();
@@ -697,9 +714,6 @@ async fn remove_orphaned_ews_events(
697714
.bind(event_id)
698715
.execute(pool)
699716
.await;
700-
// Same booking-cancel semantics as the CalDAV path, minus the
701-
// CalDAV-only HTTP double-check: there's no `CaldavClient` to
702-
// verify with for EWS sources.
703717
cancel_orphaned_booking_simple(pool, key, uid).await;
704718
deleted += 1;
705719
}
@@ -711,10 +725,12 @@ async fn remove_orphaned_ews_events(
711725
/// confirmed booking by UID and marks it cancelled. Skips the
712726
/// `cancel_orphaned_booking` HTTP confirm step (CalDAV-specific).
713727
async fn cancel_orphaned_booking_simple(pool: &SqlitePool, _key: &[u8; 32], uid: &str) {
714-
let _ = sqlx::query("UPDATE bookings SET status = 'cancelled' WHERE uid = ? AND status = 'confirmed'")
715-
.bind(uid)
716-
.execute(pool)
717-
.await;
728+
let _ = sqlx::query(
729+
"UPDATE bookings SET status = 'cancelled' WHERE uid = ? AND status = 'confirmed'",
730+
)
731+
.bind(uid)
732+
.execute(pool)
733+
.await;
718734
}
719735

720736
// --- Helper functions ---

src/ews/autodiscover.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ pub async fn discover_ews_url(email: &str, password: &str) -> Result<String> {
3939
.timeout(TIMEOUT)
4040
// Follow up to two redirects — Autodiscover often replies 302 to a
4141
// canonical URL on the same domain.
42+
//
43+
// Known limitation: the SSRF validator only runs on the initial
44+
// candidate URL, not on intermediate Location headers. A malicious
45+
// autodiscover responder could redirect to a private hostname mid
46+
// chain. The chain is HTTPS, so the attacker would also need a
47+
// valid cert for the private host; we accept the residual risk and
48+
// rely on the egress firewall mitigation (see docs/src/security.md).
49+
// The server-supplied EwsUrl from the final response is revalidated
50+
// below before being persisted.
4251
.redirect(Policy::limited(2))
4352
.build()
4453
.context("HTTP client build failed")?;

src/ews/ical.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,18 @@ pub fn synth_vcalendar(item: &EwsCalendarItem) -> Option<String> {
4848
"CONFIRMED"
4949
};
5050

51+
// RFC 5545 requires DTSTAMP on every VEVENT. EWS doesn't expose a stable
52+
// "last modified" we can map to it, so we emit "now" — strict consumers
53+
// get a valid VEVENT, calrs availability code ignores DTSTAMP.
54+
let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
55+
5156
let mut buf = String::new();
5257
buf.push_str("BEGIN:VCALENDAR\r\n");
5358
buf.push_str("VERSION:2.0\r\n");
5459
buf.push_str("PRODID:-//calrs//ews-bridge//EN\r\n");
5560
buf.push_str("BEGIN:VEVENT\r\n");
5661
buf.push_str(&format!("UID:{uid}\r\n"));
62+
buf.push_str(&format!("DTSTAMP:{dtstamp}\r\n"));
5763
buf.push_str(&format!("DTSTART{dtstart}\r\n"));
5864
buf.push_str(&format!("DTEND{dtend}\r\n"));
5965
if !summary.is_empty() {
@@ -73,6 +79,14 @@ pub fn synth_vcalendar(item: &EwsCalendarItem) -> Option<String> {
7379
/// `2026-05-08T00:00:00`) as the iCal property value, including the right
7480
/// VALUE/TZID hint. EWS stores naive-local-with-timezone or UTC; the
7581
/// generated iCal mirrors the source semantics.
82+
///
83+
/// TODO: naive-local datetimes are currently emitted as floating values
84+
/// (no `TZID` parameter). EWS normally returns UTC, but tenants with a
85+
/// non-UTC default may surface naive locals, which would be misread as
86+
/// the host's local time during availability checks. Recurring events
87+
/// already escape via the MIME path; the gap is limited to non-recurring
88+
/// naive-local items synthesised from `FindItem`. Tracking in the issue
89+
/// tracker for a follow-up.
7690
fn format_dt(value: &str, all_day: bool) -> String {
7791
if all_day {
7892
// All-day events use VALUE=DATE with YYYYMMDD.

src/ews/mod.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,12 @@ impl CalendarProvider for EwsProvider {
123123
// Cursor-seeding mode (see trait docs): the caller has already
124124
// populated the local cache via `fetch_events` and only wants a
125125
// starting cursor. EWS's `SyncFolderItems` without a state walks
126-
// the entire folder before returning a cursor — extremely costly
127-
// on busy mailboxes. We skip it: the next background full sync
128-
// will bootstrap the cursor naturally on the first incremental
129-
// call. Until then, EWS sources fall back to `fetch_events_since`,
130-
// which uses a `CalendarView` window and is already efficient.
126+
// the entire folder before returning one, which is prohibitively
127+
// costly on large mailboxes (and there's no smaller cursor-only
128+
// EWS call to swap in). For now, EWS sources rely on full fetches
129+
// via `fetch_events` — `stored_sync_state` stays `None` and every
130+
// sync re-fetches the folder. The follow-up is to swap in a
131+
// `CalendarView`-based incremental sync, see issue tracker.
131132
if sync_state.is_none() {
132133
return Ok(DeltaResult::default());
133134
}

src/ews/operations.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,17 +318,21 @@ pub async fn delete_item(
318318
///
319319
/// `sync_state` is the opaque cursor returned by the previous call (or
320320
/// `None` for the initial sync). We request `MaxChangesReturned=512` and
321-
/// loop until the server reports `IncludesLastItemInRange=true`.
321+
/// loop until the server reports `IncludesLastItemInRange=true`, with a
322+
/// hard cap of `MAX_SYNC_PAGES` iterations as insurance against a server
323+
/// that never sets the terminator (200 pages × 512 changes = 102 400
324+
/// items, far above any realistic mailbox).
322325
pub async fn sync_folder_items(
323326
endpoint: &str,
324327
username: &str,
325328
password: &str,
326329
folder_id: &str,
327330
sync_state: Option<&str>,
328331
) -> Result<EwsSyncDelta> {
332+
const MAX_SYNC_PAGES: usize = 200;
329333
let mut state = sync_state.map(str::to_string);
330334
let mut all = EwsSyncDelta::default();
331-
loop {
335+
for _ in 0..MAX_SYNC_PAGES {
332336
let state_xml = state
333337
.as_deref()
334338
.map(|s| format!(" <m:SyncState>{}</m:SyncState>\n", escape(s)))
@@ -361,8 +365,17 @@ pub async fn sync_folder_items(
361365
state = all.new_sync_state.clone();
362366

363367
if page.includes_last {
364-
break;
368+
return Ok(all);
365369
}
366370
}
371+
// Hit the safety cap without seeing IncludesLastItemInRange=true: either
372+
// the server is broken or the mailbox is genuinely enormous. Return what
373+
// we have plus the latest cursor so the next sync can resume.
374+
tracing::warn!(
375+
folder_id = %folder_id,
376+
pages = MAX_SYNC_PAGES,
377+
"SyncFolderItems hit the safety cap without IncludesLastItemInRange=true; \
378+
returning partial result, next sync will resume from cursor"
379+
);
367380
Ok(all)
368381
}

src/ews/parse.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,9 @@ pub fn extract_vcalendar(mime: &str) -> Option<String> {
199199
let close = end + "END:VCALENDAR".len();
200200
let mut block = mime[begin..close].to_string();
201201
// MIME line endings are CRLF; iCal already requires CRLF, so we leave it
202-
// alone — but normalise stray CR or accidental indentation.
202+
// alone. RFC 5545 §3.1 long-content lines are folded with a CRLF followed
203+
// by a single space or tab; unfold them here so downstream parsers see
204+
// logical lines rather than the wire format.
203205
block = block.replace("\r\n ", "");
204206
block = block.replace("\r\n\t", "");
205207
Some(block)

0 commit comments

Comments
 (0)