Feat/ews provider : add Microsoft Exchange (EWS) calendar backend#103
Conversation
olivierlambert
left a comment
There was a problem hiding this comment.
@huntervcx thanks for putting this together! 🙏 First read from me. Big PR, but very approachable: the trait + factory split is sharp, the EWS module is well-organised, and the security work shows real care (RFC1918 + CGNAT + IPv6 ULA SSRF screen, validator re-run on the server-returned EwsUrl, escape() on every user-controlled XML interpolation, SendMeetingInvitations="SendToNone" / HardDelete / SendMeetingCancellations="SendToNone" with comments explaining why). The architecture lands well; most of what follows is polish.
Things that look really good
- Trait shape. Opaque
idonRemoteCalendarplus separatechange_markerandsync_stateis exactly the right abstraction: it lets the CalDAV ctag/sync-token model and EWS ChangeKey/SyncState model coexist without leaking provider details into sync code. The CalDAV adapter is a thin translation layer, so the existing CalDAV behaviour is preserved end-to-end. - SSRF defense.
validate_caldav_urlruns on each candidate autodiscover URL and on the server-suppliedEwsUrlbefore persisting. The honest comment about DNS rebinding and the egress-firewall mitigation is the right framing.CALRS_ALLOW_PRIVATE_HOSTSopt-in plus theWARNlog atservestartup (src/main.rs:140) is the right shape too. - EWS semantics.
SendMeetingInvitations="SendToNone"keeps EWS from firing duplicate invites alongside the SMTP path.DeleteType="HardDelete"avoids the tombstone-in-Trash freebusy issue.SyncScope=NormalItemscorrectly excludes recurring exceptions.MaxEntriesReturned/MaxChangesReturnedare bounded.synth_or_fetch_mimeskips a round trip for non-recurring items. - iCal synthesis.
escape_ical_textis RFC 5545 compliant (\\,\,,\;,\n, strip\r). TRANSP mapping (Free/Tentative→TRANSPARENT) is exactly what calrs's availability logic expects, and theSTATUS:CANCELLEDmapping mirrors the CalDAV path. - Tests. Good coverage of the EWS-specific logic: autodiscover parsing (modern + ASUrl fallback + no-match), SOAP fault detection, FindFolder, FindItem with CalendarView, CreateItem id extraction, sync delta with create/update/delete, MIME extraction, iCal synth (basic / all-day / TRANSP / CANCELLED / UID fallback), and the upper-bound math.
cargo clippyis clean.
Blockers (small but they need fixing before merge)
1. Two migration tests are failing. cargo test on the branch reports:
db::tests::migrate_is_idempotent
db::tests::migrate_tracks_applied_migrations
Both assert count == 50 at src/db.rs:807 and :830. Migration 051_provider_type.sql brings the total to 51, so the assertions (and their message strings) need bumping. Pre-commit runs cargo test, so this would have been caught locally too.
2. Migration number collision with main. 1.11.0 shipped migrations/051_per_member_frequency_limit.sql to main (#118), which collides with this PR's 051_provider_type.sql. Same dance as #99: rebase on main, rename to migrations/052_provider_type.sql, update the migrations array in src/db.rs, and bump the test count to 52 once you do.
Substantive but not blocking
3. EWS delta sync never seeds a cursor. In EwsProvider::sync_delta, sync_state = None returns DeltaResult::default() early (with the well-reasoned "SyncFolderItems-from-zero walks the entire folder" comment). The implication is that sync_source never gets a new_sync_state to store for EWS sources, so stored_sync_state stays None forever and EWS sources always take the full-fetch path. The code is correct, but the trade-off is more permanent than the comment suggests. Two options:
- Acknowledge it explicitly: rename the comment from "the next background full sync will bootstrap the cursor naturally on the first incremental call" to something like "EWS sources rely on full fetches", and file a follow-up issue for a CalendarView-based incremental.
- Wire the cursor seed properly: have
sync_delta(_, None)issue an empty SyncFolderItems just to grab the initialSyncState(no items returned thanks toMaxChangesReturned=0if you set it small), then the next sync can use the cursor.
Either is fine; I'd prefer the first since it preserves the perf reasoning.
4. fetch_events_since is dead code in the application. Defined on the trait, implemented by both providers, but nothing in src/commands/sync.rs or src/web/mod.rs actually calls it. Not a regression from this PR, but the trait surface now formalises it. Two options: wire it up to sync_if_stale (the on-demand path) for the perf win on both CalDAV and EWS, or drop it from the trait until something needs it. The EWS CalendarView implementation is good, it would be a shame to leave it idle.
5. Autodiscover redirect target isn't validated. Policy::limited(2) follows up to two redirects, but only the initial URL goes through validate_caldav_url. A malicious or misconfigured autodiscover responder could redirect to a private hostname mid-chain. Severity is low (the chain is HTTPS, the attacker would need to also serve a valid TLS cert for the private host), but a custom redirect policy that re-runs the validator per hop would close the gap. Optional; a comment marking it as a known limitation would also be fine.
6. EWS synth omits DTSTAMP. RFC 5545 requires DTSTAMP on every VEVENT. The icalendar crate is lenient about this in calrs's parsing path, but a stricter consumer (or future static analysis) would balk. Three lines to add in synth_vcalendar.
7. EWS naive-local datetimes are floated. format_dt emits the value without TZID when there's no trailing Z. EWS by default returns UTC, but if a tenant has a non-UTC default and emits naive locals, the synthesised iCal will be interpreted as floating and slot computation could drift. The MIME path is the proper escape hatch (and the code does fall through to it for recurrences), so this only bites non-recurring naive-local events. Worth a TODO + tracking issue.
8. sync_folder_items outer loop has no safety cap. loop { ...; if page.includes_last { break; } } — a buggy or pathological server that never sets IncludesLastItemInRange=true will spin until something else kills the request. A for _ in 0..MAX_ITERS cap (say 200, given MaxChangesReturned=512) would be cheap insurance.
9. Schema columns are reused but lie about their names. calendars.href now holds EWS ItemId values, calendars.ctag holds EWS ChangeKey values. The code is correct (and you've added comments noting this), but the column names will misdirect anyone reading the schema cold. Either rename (more work, requires a migration) or add a -- See providers/mod.rs::RemoteCalendar comment on the schema. Pragmatic to leave, just worth flagging.
Minor nits
- One em-dash slipped through in
templates/source_form.htmlline 69 (the new EWS help text). The project convention is no em-dashes (feedback_em_dashes.md). Trivial replace with a comma or period. - CLAUDE.md was updated to add 051 to the migrations index. After renumbering to 052 you'll need to update CLAUDE.md too.
extract_vcalendarline-unfolding strips\r\nand\r\n\t— that's actually RFC 5545 line continuation, not "stray indentation" as the comment says. Behaviourally fine since iCal consumers expect unfolded text; just a comment-vs-code mismatch.
On compatibility
You called out that "MS Exchange 2019 does not support caldav without some bridge" in the inline comment, which matches what the PR docs say. Worth folding that note into the doc that ships with this (the module mentions "see docs/ews.md (planned)") so operators know to look for EWS, not CalDAV, when they hit a 2019 server.
Overall this is a strong piece of work and the security posture is excellent. Fix the two blocking items (test counts + migration renumber + rebase) and at least pick a path on #3 (EWS delta sync), and I'm happy to come back for a final pass and merge. The substantive items #4-#9 we can either fold into this PR or take as fast follow-ups, your call.
Thanks again for the EWS work, calrs is going to reach a lot more on-prem operators because of it 🙂
olivierlambert
left a comment
There was a problem hiding this comment.
Flipping to Changes Requested per the review above so the badge reflects state. Two concrete blockers, both small:
- Two failing migration tests (
migrate_is_idempotent,migrate_tracks_applied_migrations) still assert the old count atsrc/db.rs:807/:830. Bump to match the new total. - Migration
051_provider_type.sqlcollides withmain's051_per_member_frequency_limit.sql(shipped in 1.11.0). Rebase + renumber to052_provider_type.sqlplus update thesrc/db.rsentry.
The substantive items #3 to #9 from the review can ride along in this PR or land as follow-ups, your call. Architecture and security work are solid 🙂
cd1dcdd to
04884ec
Compare
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>
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>
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>
04884ec to
2bf4c52
Compare
|
Hi @olivierlambert Blockers
Substantive items
Nits
Smoke test
Sorry if some things are not compliant, i'm not an expert in rust, but still ready for another pass when you have a moment. |
olivierlambert
left a comment
There was a problem hiding this comment.
Thanks for the fast turnaround, this revision checks out on every point.
Verified locally on the rebased head:
cargo fmt --check,cargo clippy --all-targets -- -D warnings, andcargo test(728 passed) all green- Migration
055_provider_type.sqlregistered insrc/db.rs, both count assertions at 55 fetch_events_sincewired intosync_ews_source, andremove_orphaned_ews_eventsmirrors the CalDAV variant's window scoping and empty-response guard exactlysync_folder_itemscap,DTSTAMP, autodiscover redirect comment,format_dtTODO, and the allowlist-basedCALRS_ALLOW_PRIVATE_HOSTSall landed as described- Nice bonus cleanup removing the smoke-test scaffolding
No worries about Exchange 2007, it has been EOL since 2017 and not worth supporting. Merging, and thanks for the contribution!
The provider-expansion release: connecting a calendar no longer means CalDAV with basic auth. Microsoft Exchange (EWS) joins as a second backend behind a new provider trait, Google Calendar connects via OAuth2 with encrypted token storage, confirmed bookings can auto-generate video meeting links (Jitsi pattern or bring-your-own webhook), booking pages gain an opt-in self-hosted proof-of-work captcha, and an embed system lets you put your booking page on any website. Two headline features are community contributions. Highlights: - Microsoft Exchange (EWS) calendar backend (#103, #127) - Google Calendar (OAuth2) sources (#99) - Auto-generated video meeting links: Jitsi + webhook (#128) - Self-hosted proof-of-work booking captcha (#122, @florian-SV) - Embed code generator: inline, floating button, element click (#125) - calrs config dump CLI command (#112, @mvalois) - CALRS_ALLOW_PRIVATE_HOSTS for private CalDAV/EWS hosts (#124) - Fixed: Google forward-window sync truncation, CalDAV write-back no longer gated on SMTP (#99), friendly booking email validation (#129) - Migrations 053-056; translations update (#131); 758 tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ert#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>
The provider-expansion release: connecting a calendar no longer means CalDAV with basic auth. Microsoft Exchange (EWS) joins as a second backend behind a new provider trait, Google Calendar connects via OAuth2 with encrypted token storage, confirmed bookings can auto-generate video meeting links (Jitsi pattern or bring-your-own webhook), booking pages gain an opt-in self-hosted proof-of-work captcha, and an embed system lets you put your booking page on any website. Two headline features are community contributions. Highlights: - Microsoft Exchange (EWS) calendar backend (olivierlambert#103, olivierlambert#127) - Google Calendar (OAuth2) sources (olivierlambert#99) - Auto-generated video meeting links: Jitsi + webhook (olivierlambert#128) - Self-hosted proof-of-work booking captcha (olivierlambert#122, @florian-SV) - Embed code generator: inline, floating button, element click (olivierlambert#125) - calrs config dump CLI command (olivierlambert#112, @mvalois) - CALRS_ALLOW_PRIVATE_HOSTS for private CalDAV/EWS hosts (olivierlambert#124) - Fixed: Google forward-window sync truncation, CalDAV write-back no longer gated on SMTP (olivierlambert#99), friendly booking email validation (olivierlambert#129) - Migrations 053-056; translations update (olivierlambert#131); 758 tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>


Summary
Adds Microsoft Exchange (EWS) as an alternative to CalDAV for connecting on-prem
Exchange 2013/2016/2019 mailboxes. Users can now pick the backend protocol when
adding a calendar source; the rest of the booking flow (sync, write-back,
availability) is unchanged.
ewsprovider built on a minimal SOAP client (src/ews/): autodiscover,GetFolder / FindItem / CreateItem / DeleteItem, iCal ↔ EWS field mapping.
src/providers/) abstracts CalDAV vs EWS so the rest ofthe codebase talks to a single trait. CalDAV path is unchanged.
migrations/050_provider_type.sqladdsprovider_typeoncaldav_sources(defaults tocaldavfor existing rows — backward compatible).dropdown is filtered client-side so EWS presets only appear when EWS is
selected, and vice-versa.
CALRS_ALLOW_PRIVATE_HOSTS=1opt-in env var to allowCalDAV/EWS URLs that resolve to RFC1918 / loopback IPs. Logs a
WARNatstartup when set. Useful for self-hosted Exchange behind private addressing;
off by default.
Test plan
cargo build --releasecleancargo fmt --checkcleanpreset dropdown shows CalDAV options only.
write-back picks the right folder.
Exchange mailbox, cancellation removes it.
stays consistent (no EWS option while CalDAV is selected).
CALRS_ALLOW_PRIVATE_HOSTS, adding a source pointing at aprivate IP is rejected with the existing SSRF error.
CALRS_ALLOW_PRIVATE_HOSTS=1, the same URL is accepted and thestartup log shows the SSRF-disabled warning.