diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f86c7..63c8afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. This projec ### Fixed - IMAP: Import fails with `BAD` on servers that advertise `LIST-EXTENDED` without `SPECIAL-USE`. +- MS Exchange EWS: add support for version negotiation and other enhancements (#6). ## [1.0.1] - 2026-06-04 @@ -18,7 +19,7 @@ All notable changes to this project will be documented in this file. This projec ### Changed ### Fixed -- MS Exchange duplicate ids and incorrect JSCalendar mapping issues. +- MS Exchange Graph: duplicate ids and incorrect JSCalendar mapping issues. ## [1.0.0] - 2026-05-29 diff --git a/Cargo.lock b/Cargo.lock index f123d50..49160a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1999,9 +1999,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -2022,9 +2022,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "ring" @@ -2930,9 +2930,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "sha1_smol", ] @@ -3503,18 +3503,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index faa8735..a8c424a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ mail-parser = "0.11.3" calcard = "0.3" rustls = { version = "0.23.40", default-features = false, features = ["aws-lc-rs", "std", "tls12", "logging"] } rustls-platform-verifier = "0.7" -regex = "1.11" +regex = "1.12" flate2 = "1.1.9" quick-xml = "0.40" chrono = { version = "0.4", default-features = false, features = ["std"] } diff --git a/src/exchange_ews/calendar_map.rs b/src/exchange_ews/calendar_map.rs index 019f379..1de4349 100644 --- a/src/exchange_ews/calendar_map.rs +++ b/src/exchange_ews/calendar_map.rs @@ -141,13 +141,19 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue { &mut participants, &mut next_id, &raw.required_attendees, - true, + AttendeeRole::Required, ); add_attendees( &mut participants, &mut next_id, &raw.optional_attendees, - false, + AttendeeRole::Optional, + ); + add_attendees( + &mut participants, + &mut next_id, + &raw.resources, + AttendeeRole::Resource, ); if !participants.is_empty() { event.insert("participants".to_owned(), Value::Object(participants)); @@ -165,6 +171,44 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue { if let Some(overrides) = overrides { event.insert("recurrenceOverrides".to_owned(), overrides); } + if raw.reminder_is_set == Some(true) { + let minutes = raw.reminder_minutes_before_start.unwrap_or(0).max(0); + let offset = if minutes == 0 { + "PT0S".to_owned() + } else { + format!("-PT{minutes}M") + }; + let alert = json!({ + "@type": "Alert", + "trigger": {"@type": "OffsetTrigger", "offset": offset, "relativeTo": "start"}, + "action": "display", + }); + event.insert( + "alerts".to_owned(), + Value::Object(map_singleton("1", alert)), + ); + } + if let Some(url) = raw + .join_online_meeting_url + .as_deref() + .or(raw.net_show_url.as_deref()) + .or(raw.meeting_workspace_url.as_deref()) + .filter(|s| !s.is_empty()) + { + let mut vl = Map::new(); + vl.insert( + "@type".to_owned(), + Value::String("VirtualLocation".to_owned()), + ); + vl.insert("uri".to_owned(), Value::String(url.to_owned())); + if raw.is_online_meeting == Some(true) { + vl.insert("features".to_owned(), json!({"video": true})); + } + event.insert( + "virtualLocations".to_owned(), + Value::Object(map_singleton("1", Value::Object(vl))), + ); + } EventValue { data: Value::Object(event), is_draft: false, @@ -172,46 +216,79 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AttendeeRole { + Required, + Optional, + Resource, +} + fn add_attendees( out: &mut Map, next_id: &mut u32, attendees: &[RawAttendee], - required: bool, + role: AttendeeRole, ) { for att in attendees { let key = next_id.to_string(); *next_id += 1; let mut p = Map::new(); p.insert("@type".to_owned(), Value::String("Participant".to_owned())); - if let Some(email) = att.email.as_ref() { - p.insert( - "calendarAddress".to_owned(), - Value::String(format!("mailto:{email}")), - ); - p.insert("email".to_owned(), Value::String(email.clone())); - } if let Some(name) = att.name.as_ref() { p.insert("name".to_owned(), Value::String(name.clone())); } - let mut roles = Map::new(); - if required { - roles.insert("required".to_owned(), Value::Bool(true)); - } else { - roles.insert("optional".to_owned(), Value::Bool(true)); - } - p.insert("roles".to_owned(), Value::Object(roles)); - if let Some(rt) = att.response_type.as_ref() { - let mapped = match rt.as_str() { - "Accept" => "accepted", - "Tentative" => "tentative", - "Decline" => "declined", - "Organizer" => "accepted", - _ => "needs-action", - }; - p.insert( - "participationStatus".to_owned(), - Value::String(mapped.to_owned()), - ); + match att.email.as_ref() { + Some(email) => { + p.insert( + "calendarAddress".to_owned(), + Value::String(format!("mailto:{email}")), + ); + p.insert("email".to_owned(), Value::String(email.clone())); + let mut roles = Map::new(); + match role { + AttendeeRole::Required | AttendeeRole::Resource => { + roles.insert("required".to_owned(), Value::Bool(true)); + } + AttendeeRole::Optional => { + roles.insert("optional".to_owned(), Value::Bool(true)); + } + } + p.insert("roles".to_owned(), Value::Object(roles)); + if role == AttendeeRole::Resource { + p.insert("kind".to_owned(), Value::String("resource".to_owned())); + } else { + p.insert("expectReply".to_owned(), Value::Bool(true)); + } + if let Some(rt) = att.response_type.as_ref() { + let mapped = match rt.as_str() { + "Accept" => "accepted", + "Tentative" => "tentative", + "Decline" => "declined", + "Organizer" => "accepted", + "NoResponseReceived" => "needs-action", + _ => "needs-action", + }; + p.insert( + "participationStatus".to_owned(), + Value::String(mapped.to_owned()), + ); + } + } + None => { + if let Some(rt) = att.response_type.as_ref() { + let mapped = match rt.as_str() { + "Accept" => "accepted", + "Tentative" => "tentative", + "Decline" => "declined", + "Organizer" => "accepted", + _ => "needs-action", + }; + p.insert( + "participationStatus".to_owned(), + Value::String(mapped.to_owned()), + ); + } + } } out.insert(key, Value::Object(p)); } @@ -571,6 +648,121 @@ mod tests { assert_eq!(optional["roles"]["optional"], true); } + #[test] + fn reminder_becomes_offset_trigger_alert() { + let raw = CalendarItemRaw { + uid: Some("uid-alarm".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + reminder_is_set: Some(true), + reminder_minutes_before_start: Some(15), + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + let alert = &v["alerts"]["1"]; + assert_eq!(alert["@type"], "Alert"); + assert_eq!(alert["action"], "display"); + assert_eq!(alert["trigger"]["@type"], "OffsetTrigger"); + assert_eq!(alert["trigger"]["offset"], "-PT15M"); + assert_eq!(alert["trigger"]["relativeTo"], "start"); + } + + #[test] + fn reminder_not_set_emits_no_alerts() { + let raw = CalendarItemRaw { + uid: Some("uid-noalarm".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + reminder_is_set: Some(false), + reminder_minutes_before_start: Some(15), + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + assert!(v.get("alerts").is_none()); + } + + #[test] + fn online_meeting_becomes_virtual_location() { + let raw = CalendarItemRaw { + uid: Some("uid-online".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + is_online_meeting: Some(true), + net_show_url: Some("https://teams.example/join/abc".to_owned()), + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + let vl = &v["virtualLocations"]["1"]; + assert_eq!(vl["@type"], "VirtualLocation"); + assert_eq!(vl["uri"], "https://teams.example/join/abc"); + assert_eq!(vl["features"]["video"], true); + } + + #[test] + fn join_url_preferred_over_workspace_url() { + let raw = CalendarItemRaw { + uid: Some("uid-join".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + join_online_meeting_url: Some("https://join/primary".to_owned()), + meeting_workspace_url: Some("https://workspace/secondary".to_owned()), + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + assert_eq!(v["virtualLocations"]["1"]["uri"], "https://join/primary"); + } + + #[test] + fn resources_get_resource_kind_and_no_expect_reply() { + let raw = CalendarItemRaw { + uid: Some("uid-res".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + resources: vec![crate::exchange_ews::parse::RawAttendee { + email: Some("room-7@x".to_owned()), + name: Some("Room 7".to_owned()), + response_type: Some("Accept".to_owned()), + }], + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + let res = v["participants"] + .as_object() + .unwrap() + .values() + .find(|p| p["email"] == "room-7@x") + .unwrap(); + assert_eq!(res["kind"], "resource"); + assert_eq!(res["roles"]["required"], true); + assert_eq!(res["participationStatus"], "accepted"); + assert!(res.get("expectReply").is_none()); + } + + #[test] + fn attendees_carry_expect_reply_and_participation_status() { + let raw = CalendarItemRaw { + uid: Some("uid-rsvp".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + required_attendees: vec![crate::exchange_ews::parse::RawAttendee { + email: Some("bob@x".to_owned()), + name: None, + response_type: Some("Tentative".to_owned()), + }], + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + let bob = v["participants"] + .as_object() + .unwrap() + .values() + .find(|p| p["email"] == "bob@x") + .unwrap(); + assert_eq!(bob["expectReply"], true); + assert_eq!(bob["participationStatus"], "tentative"); + assert_eq!(bob["roles"]["required"], true); + } + #[test] fn all_day_span_helper_is_inclusive_of_start() { assert_eq!(all_day_span_days("2025-07-04", "2025-07-04"), 1); diff --git a/src/exchange_ews/client.rs b/src/exchange_ews/client.rs index 6d24010..cdacb50 100644 --- a/src/exchange_ews/client.rs +++ b/src/exchange_ews/client.rs @@ -16,7 +16,7 @@ use ureq::config::{Config, RedirectAuthHeaders}; use ureq::tls::{RootCerts, TlsConfig}; use crate::exchange_ews::error::EwsError; -use crate::exchange_ews::parse::{EnvelopeKind, read_envelope_summary}; +use crate::exchange_ews::parse::{EnvelopeKind, SoapFault, read_envelope_summary}; use crate::exchange_ews::retry::{FaultDisposition, classify_fault, classify_http_status}; use crate::exchange_ews::soap::{EnvelopeOptions, soap_action, wrap_envelope}; use crate::exchange_ews::types::ServerVersion; @@ -32,6 +32,7 @@ struct Inner { auth: Mutex, impersonated_smtp: Mutex>, anchor_mailbox: Mutex>, + affinity_cookie: Mutex>, retry: RetryPolicy, rate_limit: RateLimitState, version: Mutex, @@ -71,6 +72,7 @@ impl EwsClient { auth: Mutex::new(auth), impersonated_smtp: Mutex::new(None), anchor_mailbox: Mutex::new(None), + affinity_cookie: Mutex::new(None), retry, rate_limit: RateLimitState::new(), version: Mutex::new(ServerVersion::Exchange2013Sp1), @@ -142,10 +144,9 @@ impl EwsClient { } pub fn call(&self, url: &str, operation: &str, body: &str) -> Result { - let envelope = self.wrap(body); let action = soap_action(operation); self.inner.soap_calls.fetch_add(1, Ordering::Relaxed); - self.execute(url, &envelope, &action) + self.execute(url, body, &action) } fn wrap(&self, body: &str) -> String { @@ -182,22 +183,33 @@ impl EwsClient { .and_then(|g| g.clone()) } + fn affinity_cookie(&self) -> Option { + self.inner + .affinity_cookie + .lock() + .ok() + .and_then(|g| g.clone()) + } + fn execute(&self, url: &str, body: &str, action: &str) -> Result { let logger = self.logger(); let policy = self.inner.retry; let mut attempt: u32 = 0; loop { self.inner.rate_limit.cooldown().wait(); - let outcome = self.one_attempt(url, body, action); + let sent_version = self.server_version(); + let envelope = self.wrap(body); + let outcome = self.one_attempt(url, &envelope, action); match outcome { AttemptOutcome::Ok { status, body: bytes, retry_after, } => { + let summary = read_envelope_summary(&bytes); if (200..300).contains(&status) { self.inner.rate_limit.on_success(); - match read_envelope_summary(&bytes) { + match summary { Ok(EnvelopeKind::Body { version }) => { if let Some(v) = version.to_server_version() { self.set_server_version(v); @@ -207,53 +219,22 @@ impl EwsClient { server_version: version.to_server_version(), }); } - Ok(EnvelopeKind::Fault { fault, version }) => { - if let Some(v) = version.to_server_version() { - self.set_server_version(v); - } - match classify_fault(&fault.response_code) { - FaultDisposition::Fatal => { - return Err(EwsError::SoapFault { - code: fault.response_code, - reason: fault.fault_string, - }); + Ok(EnvelopeKind::Fault { fault, .. }) => { + match self.on_fault( + &fault, + sent_version, + action, + &policy, + &logger, + &mut attempt, + ) { + FaultStep::Fail(e) => return Err(e), + FaultStep::Sleep(d) => { + std::thread::sleep(d); + continue; } - FaultDisposition::Auth => { - return Err(EwsError::Auth(fault.fault_string)); - } - FaultDisposition::Retryable { delay } => { - attempt += 1; - self.inner.retries_total.fetch_add(1, Ordering::Relaxed); - if attempt > policy.max_retries { - return Err(EwsError::RetriesExhausted(format!( - "{operation} kept returning {code}", - operation = action, - code = fault.response_code - ))); - } - let chosen = - self.inner.rate_limit.on_throttle(&policy, delay); - if chosen >= LONG_RETRY_THRESHOLD { - logger.warn(&format!( - "EWS soap fault {} ({}); waiting {}s before retry {}/{}", - fault.response_code, - fault.fault_string, - chosen.as_secs(), - attempt, - policy.max_retries - )); - } - if logger.enabled(LEVEL_BODIES) { - eprintln!( - "retry {}/{} {} after {:?} ({})", - attempt, - policy.max_retries, - action, - chosen, - fault.response_code, - ); - } - std::thread::sleep(chosen); + FaultStep::Downgrade(v) => { + self.set_server_version(v); continue; } } @@ -261,12 +242,41 @@ impl EwsClient { Err(e) => return Err(e), } } + if status != 401 + && status != 403 + && let Ok(EnvelopeKind::Fault { fault, .. }) = summary + { + match self.on_fault( + &fault, + sent_version, + action, + &policy, + &logger, + &mut attempt, + ) { + FaultStep::Fail(e) => return Err(e), + FaultStep::Sleep(d) => { + std::thread::sleep(d); + continue; + } + FaultStep::Downgrade(v) => { + self.set_server_version(v); + continue; + } + } + } if status == 401 { return Err(EwsError::Auth(format!( "server returned 401: {}", truncate(&bytes) ))); } + if status == 456 { + return Err(EwsError::Auth(format!( + "account is locked (http 456); an administrator must unlock it: {}", + truncate(&bytes) + ))); + } if status == 403 { return Err(EwsError::HttpStatus { status: 403, @@ -331,6 +341,71 @@ impl EwsClient { } } + fn on_fault( + &self, + fault: &SoapFault, + sent_version: ServerVersion, + action: &str, + policy: &RetryPolicy, + logger: &Logger, + attempt: &mut u32, + ) -> FaultStep { + match classify_fault(&fault.response_code) { + FaultDisposition::Fatal => FaultStep::Fail(EwsError::SoapFault { + code: fault.response_code.clone(), + reason: fault.fault_string.clone(), + }), + FaultDisposition::Auth => FaultStep::Fail(EwsError::Auth(fault.fault_string.clone())), + FaultDisposition::VersionError => match sent_version.lower() { + Some(next) => { + logger.warn(&format!( + "EWS rejected schema version {} ({}); retrying as {}", + sent_version.as_str(), + fault.response_code, + next.as_str() + )); + FaultStep::Downgrade(next) + } + None => FaultStep::Fail(EwsError::SoapFault { + code: fault.response_code.clone(), + reason: format!( + "server rejected every supported EWS schema version (last tried {}): {}", + sent_version.as_str(), + fault.fault_string + ), + }), + }, + FaultDisposition::Retryable { delay } => { + *attempt += 1; + self.inner.retries_total.fetch_add(1, Ordering::Relaxed); + if *attempt > policy.max_retries { + return FaultStep::Fail(EwsError::RetriesExhausted(format!( + "{action} kept returning {code}", + code = fault.response_code + ))); + } + let chosen = self.inner.rate_limit.on_throttle(policy, delay); + if chosen >= LONG_RETRY_THRESHOLD { + logger.warn(&format!( + "EWS soap fault {} ({}); waiting {}s before retry {}/{}", + fault.response_code, + fault.fault_string, + chosen.as_secs(), + *attempt, + policy.max_retries + )); + } + if logger.enabled(LEVEL_BODIES) { + eprintln!( + "retry {}/{} {} after {:?} ({})", + *attempt, policy.max_retries, action, chosen, fault.response_code, + ); + } + FaultStep::Sleep(chosen) + } + } + } + fn one_attempt(&self, url: &str, body: &str, action: &str) -> AttemptOutcome { let mut req = self .inner @@ -341,10 +416,14 @@ impl EwsClient { .header("Accept", "text/xml, application/soap+xml, application/xml") .header("Accept-Encoding", "gzip") .header("SOAPAction", action.to_owned()) - .header("User-Agent", self.inner.user_agent.as_str()); + .header("User-Agent", self.inner.user_agent.as_str()) + .header("X-PreferServerAffinity", "True"); if let Some(anchor) = self.anchor_header() { req = req.header("X-AnchorMailbox", anchor); } + if let Some(cookie) = self.affinity_cookie() { + req = req.header("X-BackEndOverrideCookie", cookie); + } let result = req.send(body.as_bytes()); match result { Ok(mut resp) => { @@ -354,6 +433,11 @@ impl EwsClient { .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(retry_after_header); + if let Some(cookie) = extract_affinity_cookie(resp.headers()) + && let Ok(mut g) = self.inner.affinity_cookie.lock() + { + *g = Some(cookie); + } match resp.body_mut().with_config().limit(MAX_BODY).read_to_vec() { Ok(bytes) => AttemptOutcome::Ok { status, @@ -379,6 +463,25 @@ enum AttemptOutcome { Transport(EwsError), } +enum FaultStep { + Fail(EwsError), + Sleep(Duration), + Downgrade(ServerVersion), +} + +fn extract_affinity_cookie(headers: &ureq::http::HeaderMap) -> Option { + for value in headers.get_all("set-cookie") { + let Ok(s) = value.to_str() else { continue }; + if let Some(rest) = s.trim_start().strip_prefix("X-BackEndOverrideCookie=") { + let val = rest.split(';').next().unwrap_or(rest).trim(); + if !val.is_empty() { + return Some(val.to_owned()); + } + } + } + None +} + fn map_ureq_error(err: ureq::Error) -> EwsError { match err { ureq::Error::Io(e) => EwsError::Transport(format!("io: {e}")), diff --git a/src/exchange_ews/contact_map.rs b/src/exchange_ews/contact_map.rs index 0deaded..5b7aa67 100644 --- a/src/exchange_ews/contact_map.rs +++ b/src/exchange_ews/contact_map.rs @@ -17,6 +17,23 @@ pub fn to_jscontact(raw: &ContactItemRaw) -> Value { let mut card = Map::new(); card.insert("@type".to_owned(), Value::String("Card".to_owned())); card.insert("version".to_owned(), Value::String("1.0".to_owned())); + if raw.is_group { + card.insert("kind".to_owned(), Value::String("group".to_owned())); + let mut members = Map::new(); + for m in &raw.members { + let key = match m.email.as_ref() { + Some(email) => format!("mailto:{email}"), + None => match m.name.as_ref() { + Some(name) => name.clone(), + None => continue, + }, + }; + members.insert(key, Value::Bool(true)); + } + if !members.is_empty() { + card.insert("members".to_owned(), Value::Object(members)); + } + } if let Some(full) = raw.display_name.as_ref() { let mut name = Map::new(); name.insert("@type".to_owned(), Value::String("Name".to_owned())); @@ -411,6 +428,41 @@ mod tests { assert!(v["onlineServices"]["1"].get("service").is_none()); } + #[test] + fn distribution_list_becomes_group_card_with_members() { + let raw = ContactItemRaw { + display_name: Some("The Team".to_owned()), + is_group: true, + members: vec![ + crate::exchange_ews::parse::RawGroupMember { + name: Some("Bob".to_owned()), + email: Some("bob@x".to_owned()), + }, + crate::exchange_ews::parse::RawGroupMember { + name: Some("No Email".to_owned()), + email: None, + }, + ], + ..ContactItemRaw::default() + }; + let v = to_jscontact(&raw); + assert_eq!(v["kind"], "group"); + assert_eq!(v["name"]["full"], "The Team"); + assert_eq!(v["members"]["mailto:bob@x"], true); + assert_eq!(v["members"]["No Email"], true); + } + + #[test] + fn individual_contact_has_no_kind_or_members() { + let raw = ContactItemRaw { + display_name: Some("Alice".to_owned()), + ..ContactItemRaw::default() + }; + let v = to_jscontact(&raw); + assert!(v.get("kind").is_none()); + assert!(v.get("members").is_none()); + } + #[test] fn unresolvable_relations_are_dropped() { let raw = ContactItemRaw { diff --git a/src/exchange_ews/parse.rs b/src/exchange_ews/parse.rs index 0f60bec..21e62e2 100644 --- a/src/exchange_ews/parse.rs +++ b/src/exchange_ews/parse.rs @@ -1031,6 +1031,13 @@ pub struct CalendarItemRaw { pub organizer_name: Option, pub required_attendees: Vec, pub optional_attendees: Vec, + pub resources: Vec, + pub reminder_is_set: Option, + pub reminder_minutes_before_start: Option, + pub is_online_meeting: Option, + pub join_online_meeting_url: Option, + pub net_show_url: Option, + pub meeting_workspace_url: Option, pub categories: Vec, pub created: Option, pub last_modified: Option, @@ -1270,6 +1277,20 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result attendee_kind = Some("required"); } else if local.eq_ignore_ascii_case(b"OptionalAttendees") { attendee_kind = Some("optional"); + } else if local.eq_ignore_ascii_case(b"Resources") { + attendee_kind = Some("resource"); + } else if local.eq_ignore_ascii_case(b"ReminderIsSet") { + text_target = Some("reminderIsSet"); + } else if local.eq_ignore_ascii_case(b"ReminderMinutesBeforeStart") { + text_target = Some("reminderMinutes"); + } else if local.eq_ignore_ascii_case(b"IsOnlineMeeting") { + text_target = Some("isOnlineMeeting"); + } else if local.eq_ignore_ascii_case(b"JoinOnlineMeetingUrl") { + text_target = Some("joinUrl"); + } else if local.eq_ignore_ascii_case(b"NetShowUrl") { + text_target = Some("netShowUrl"); + } else if local.eq_ignore_ascii_case(b"MeetingWorkspaceUrl") { + text_target = Some("workspaceUrl"); } else if local.eq_ignore_ascii_case(b"Attendee") && attendee_kind.is_some() { current_attendee = Some(RawAttendee::default()); } else if local.eq_ignore_ascii_case(b"Mailbox") { @@ -1347,11 +1368,14 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result match attendee_kind { Some("required") => item.required_attendees.push(att), Some("optional") => item.optional_attendees.push(att), + Some("resource") => item.resources.push(att), _ => {} } } } - b"requiredattendees" | b"optionalattendees" => attendee_kind = None, + b"requiredattendees" | b"optionalattendees" | b"resources" => { + attendee_kind = None + } b"organizer" => in_organizer = false, b"mailbox" => in_mailbox = false, b"categories" => category_collecting = false, @@ -1423,6 +1447,18 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result att.response_type = Some(text); } } + Some("reminderIsSet") => { + item.reminder_is_set = Some(matches!(text.trim(), "true" | "1")); + } + Some("reminderMinutes") => { + item.reminder_minutes_before_start = text.trim().parse().ok(); + } + Some("isOnlineMeeting") => { + item.is_online_meeting = Some(matches!(text.trim(), "true" | "1")); + } + Some("joinUrl") => item.join_online_meeting_url = Some(text), + Some("netShowUrl") => item.net_show_url = Some(text), + Some("workspaceUrl") => item.meeting_workspace_url = Some(text), Some("category") => item.categories.push(text), Some("created") => item.created = Some(text), Some("lastModified") => item.last_modified = Some(text), @@ -1558,6 +1594,14 @@ pub struct ContactItemRaw { pub created: Option, pub last_modified: Option, pub attachments: Vec, + pub is_group: bool, + pub members: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct RawGroupMember { + pub name: Option, + pub email: Option, } #[derive(Debug, Clone, Default)] @@ -1584,6 +1628,9 @@ pub fn parse_contact_item(inner_xml: &str) -> Result { let mut current_address: Option = None; let mut address_text: Option<&'static str> = None; let mut seen_root_contact = false; + let mut in_members = false; + let mut member_mailbox = false; + let mut current_member: Option = None; loop { buf.clear(); let (ns, ev) = xml.read_resolved_event_into(&mut buf)?; @@ -1592,8 +1639,14 @@ pub fn parse_contact_item(inner_xml: &str) -> Result { Event::Start(ref e) | Event::Empty(ref e) => { let local = e.local_name().as_ref().to_vec(); let is_empty = matches!(ev, Event::Empty(_)); - if !seen_root_contact && local.eq_ignore_ascii_case(b"Contact") { + if !seen_root_contact + && (local.eq_ignore_ascii_case(b"Contact") + || local.eq_ignore_ascii_case(b"DistributionList")) + { seen_root_contact = true; + if local.eq_ignore_ascii_case(b"DistributionList") { + item.is_group = true; + } continue; } if ns_kind != Ns::Types { @@ -1646,6 +1699,16 @@ pub fn parse_contact_item(inner_xml: &str) -> Result { text_target = Some("profession"); } else if local.eq_ignore_ascii_case(b"PostalAddressIndex") { text_target = Some("postalAddressIndex"); + } else if local.eq_ignore_ascii_case(b"Members") { + in_members = true; + } else if in_members && local.eq_ignore_ascii_case(b"Member") { + current_member = Some(RawGroupMember::default()); + } else if in_members && local.eq_ignore_ascii_case(b"Mailbox") { + member_mailbox = true; + } else if member_mailbox && local.eq_ignore_ascii_case(b"Name") { + text_target = Some("memberName"); + } else if member_mailbox && local.eq_ignore_ascii_case(b"EmailAddress") { + text_target = Some("memberEmail"); } else if local.eq_ignore_ascii_case(b"Body") { text_target = Some("notes"); } else if local.eq_ignore_ascii_case(b"DateTimeCreated") { @@ -1728,6 +1791,15 @@ pub fn parse_contact_item(inner_xml: &str) -> Result { b"street" | b"city" | b"state" | b"countryorregion" | b"postalcode" => { address_text = None; } + b"members" => in_members = false, + b"mailbox" => member_mailbox = false, + b"member" => { + if let Some(m) = current_member.take() + && (m.email.is_some() || m.name.is_some()) + { + item.members.push(m); + } + } _ => {} } text_target = None; @@ -1775,6 +1847,16 @@ pub fn parse_contact_item(inner_xml: &str) -> Result { Some("notes") => item.notes = Some(text), Some("created") => item.created = Some(text), Some("lastModified") => item.last_modified = Some(text), + Some("memberName") => { + if let Some(m) = current_member.as_mut() { + m.name = Some(text); + } + } + Some("memberEmail") => { + if let Some(m) = current_member.as_mut() { + m.email = Some(text); + } + } Some("category") => item.categories.push(text), Some("child") => item.children.push(text), Some("company") => item.companies.push(text), @@ -2162,6 +2244,59 @@ mod tests { )); } + #[test] + fn calendar_parses_reminder_resources_and_online_meeting() { + let body = format!( + "\ + \ + Standup\ + uid-r\ + true\ + 10\ + 2025-06-15T14:00:00Z\ + 2025-06-15T15:00:00Z\ + true\ + https://meet/abc\ + \ + Bobbob@xAccept\ + \ + \ + Room 7room7@x\ + \ + " + ); + let parsed = parse_calendar_item(&body).unwrap(); + assert_eq!(parsed.reminder_is_set, Some(true)); + assert_eq!(parsed.reminder_minutes_before_start, Some(10)); + assert_eq!(parsed.is_online_meeting, Some(true)); + assert_eq!(parsed.net_show_url.as_deref(), Some("https://meet/abc")); + assert_eq!(parsed.required_attendees.len(), 1); + assert_eq!(parsed.required_attendees[0].email.as_deref(), Some("bob@x")); + assert_eq!(parsed.resources.len(), 1); + assert_eq!(parsed.resources[0].email.as_deref(), Some("room7@x")); + } + + #[test] + fn distribution_list_parses_members() { + let body = format!( + "\ + \ + The Team\ + \ + Bobbob@xNormal\ + Carolcarol@x\ + \ + " + ); + let parsed = parse_contact_item(&body).unwrap(); + assert!(parsed.is_group); + assert_eq!(parsed.id.id, "DL1"); + assert_eq!(parsed.display_name.as_deref(), Some("The Team")); + assert_eq!(parsed.members.len(), 2); + assert_eq!(parsed.members[0].email.as_deref(), Some("bob@x")); + assert_eq!(parsed.members[1].name.as_deref(), Some("Carol")); + } + #[test] fn contact_parses_emails_phones_addresses() { let body = format!( diff --git a/src/exchange_ews/retry.rs b/src/exchange_ews/retry.rs index 33a7450..4e08dca 100644 --- a/src/exchange_ews/retry.rs +++ b/src/exchange_ews/retry.rs @@ -20,6 +20,7 @@ pub fn classify_http_status(status: u16) -> Disposition { pub enum FaultDisposition { Fatal, Auth, + VersionError, Retryable { delay: Option }, } @@ -35,6 +36,10 @@ pub fn classify_fault(code: &ResponseCode) -> FaultDisposition { | ResponseCode::AdUnavailable | ResponseCode::BatchProcessingStopped => FaultDisposition::Retryable { delay: None }, ResponseCode::AuthenticationRequired => FaultDisposition::Auth, + ResponseCode::InvalidServerVersion + | ResponseCode::IncorrectSchemaVersion + | ResponseCode::InvalidRequest + | ResponseCode::InvalidSchemaVersionForMailboxVersion => FaultDisposition::VersionError, _ => FaultDisposition::Fatal, } } @@ -65,6 +70,10 @@ pub fn classify_item_response(code: &ResponseCode) -> ItemAction { | ResponseCode::MimeContentConversionFailed | ResponseCode::ImpersonationFailed => ItemAction::Skip, ResponseCode::AuthenticationRequired => ItemAction::Skip, + ResponseCode::InvalidServerVersion + | ResponseCode::IncorrectSchemaVersion + | ResponseCode::InvalidRequest + | ResponseCode::InvalidSchemaVersionForMailboxVersion => ItemAction::Skip, ResponseCode::Other(_) => ItemAction::Skip, } } @@ -122,6 +131,21 @@ mod tests { assert_eq!(classify_http_status(401), Disposition::Fatal); } + #[test] + fn schema_version_codes_request_a_downgrade() { + for code in [ + ResponseCode::InvalidServerVersion, + ResponseCode::IncorrectSchemaVersion, + ResponseCode::InvalidRequest, + ResponseCode::InvalidSchemaVersionForMailboxVersion, + ] { + assert!(matches!( + classify_fault(&code), + FaultDisposition::VersionError + )); + } + } + #[test] fn transient_codes_are_retryable_at_item_and_fault_level() { for code in [ diff --git a/src/exchange_ews/types.rs b/src/exchange_ews/types.rs index f2fb225..3c1b682 100644 --- a/src/exchange_ews/types.rs +++ b/src/exchange_ews/types.rs @@ -133,6 +133,19 @@ impl ServerVersion { _ => ServerVersion::Exchange2016, } } + + pub fn lower(self) -> Option { + match self { + ServerVersion::Exchange2019 => Some(ServerVersion::Exchange2016), + ServerVersion::Exchange2016 => Some(ServerVersion::Exchange2013Sp1), + ServerVersion::Exchange2013Sp1 => Some(ServerVersion::Exchange2013), + ServerVersion::Exchange2013 => Some(ServerVersion::Exchange2010Sp2), + ServerVersion::Exchange2010Sp2 => Some(ServerVersion::Exchange2010Sp1), + ServerVersion::Exchange2010Sp1 => Some(ServerVersion::Exchange2010), + ServerVersion::Exchange2010 => Some(ServerVersion::Exchange2007), + ServerVersion::Exchange2007 => None, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -197,6 +210,10 @@ pub enum ResponseCode { MailboxStoreUnavailable, ConnectionFailed, AdUnavailable, + InvalidServerVersion, + IncorrectSchemaVersion, + InvalidRequest, + InvalidSchemaVersionForMailboxVersion, Other(String), } @@ -219,6 +236,12 @@ impl ResponseCode { "ErrorMailboxStoreUnavailable" => ResponseCode::MailboxStoreUnavailable, "ErrorConnectionFailed" => ResponseCode::ConnectionFailed, "ErrorADUnavailable" => ResponseCode::AdUnavailable, + "ErrorInvalidServerVersion" => ResponseCode::InvalidServerVersion, + "ErrorIncorrectSchemaVersion" => ResponseCode::IncorrectSchemaVersion, + "ErrorInvalidRequest" => ResponseCode::InvalidRequest, + "ErrorInvalidSchemaVersionForMailboxVersion" => { + ResponseCode::InvalidSchemaVersionForMailboxVersion + } other => ResponseCode::Other(other.to_owned()), } } @@ -241,6 +264,12 @@ impl ResponseCode { ResponseCode::MailboxStoreUnavailable => "ErrorMailboxStoreUnavailable", ResponseCode::ConnectionFailed => "ErrorConnectionFailed", ResponseCode::AdUnavailable => "ErrorADUnavailable", + ResponseCode::InvalidServerVersion => "ErrorInvalidServerVersion", + ResponseCode::IncorrectSchemaVersion => "ErrorIncorrectSchemaVersion", + ResponseCode::InvalidRequest => "ErrorInvalidRequest", + ResponseCode::InvalidSchemaVersionForMailboxVersion => { + "ErrorInvalidSchemaVersionForMailboxVersion" + } ResponseCode::Other(s) => s.as_str(), } } @@ -291,6 +320,30 @@ mod tests { ); } + #[test] + fn server_version_ladder_descends_to_floor() { + let mut v = ServerVersion::Exchange2019; + let mut chain = vec![v]; + while let Some(next) = v.lower() { + chain.push(next); + v = next; + } + assert_eq!( + chain, + vec![ + ServerVersion::Exchange2019, + ServerVersion::Exchange2016, + ServerVersion::Exchange2013Sp1, + ServerVersion::Exchange2013, + ServerVersion::Exchange2010Sp2, + ServerVersion::Exchange2010Sp1, + ServerVersion::Exchange2010, + ServerVersion::Exchange2007, + ] + ); + assert_eq!(ServerVersion::Exchange2007.lower(), None); + } + #[test] fn response_code_round_trip() { let codes = [ diff --git a/src/exchange_ews/xml.rs b/src/exchange_ews/xml.rs index b7ac5b7..7c15a4c 100644 --- a/src/exchange_ews/xml.rs +++ b/src/exchange_ews/xml.rs @@ -89,6 +89,10 @@ pub fn find_item_body( out.push_str("\" Offset=\""); out.push_str(&offset.to_string()); out.push_str("\" BasePoint=\"Beginning\"/>"); + out.push_str( + "\ + ", + ); out.push_str(""); write_folder_ref(&mut out, parent); out.push_str(""); @@ -175,6 +179,12 @@ const CALENDAR_FIELDS: &[&str] = &[ "calendar:Organizer", "calendar:RequiredAttendees", "calendar:OptionalAttendees", + "calendar:Resources", + "calendar:IsOnlineMeeting", + "calendar:MeetingWorkspaceUrl", + "calendar:NetShowUrl", + "item:ReminderIsSet", + "item:ReminderMinutesBeforeStart", "calendar:Recurrence", "calendar:ModifiedOccurrences", "calendar:DeletedOccurrences", @@ -274,6 +284,21 @@ mod tests { assert!(body.contains("")); } + #[test] + fn find_item_sorts_by_creation_time_for_stable_paging() { + let folder = FolderId::new("FID", "FCK"); + let body = find_item_body(FolderRef::Concrete(&folder), Traversal::Shallow, 0, 50); + assert!(body.contains("")); + assert!(body.contains("Order=\"Ascending\"")); + assert!(body.contains("item:DateTimeCreated")); + let sort_at = body.find("").unwrap(); + let parents_at = body.find("").unwrap(); + assert!( + sort_at < parents_at, + "SortOrder must precede ParentFolderIds" + ); + } + #[test] fn get_item_message_shape_requests_mime() { let ids = vec![ItemId::new("I1", "CK1"), ItemId::new("I2", "")]; diff --git a/src/sync/import_exchange_ews/coordinator.rs b/src/sync/import_exchange_ews/coordinator.rs index eaee836..1272ad0 100644 --- a/src/sync/import_exchange_ews/coordinator.rs +++ b/src/sync/import_exchange_ews/coordinator.rs @@ -149,6 +149,7 @@ pub fn run(common: CommonConfig, config: EwsImportConfig) -> Result { pub attachment_batch: usize, pub connections: usize, pub use_syncfolderitems: bool, + pub sync_batch: u32, pub logger: Logger, } +pub const SYNC_BATCH_MAX: u32 = 512; +const SYNC_BATCH_MIN: u32 = 32; + +fn shrink_sync_batch(current: u32) -> u32 { + (current / 2).max(SYNC_BATCH_MIN) +} + #[derive(Debug, Clone)] pub struct EnumeratedItem { pub element: String, @@ -84,8 +92,10 @@ fn try_sync_folder_items( let mut items: Vec = Vec::new(); let mut deletions: Vec = Vec::new(); let mut iters = 0; + let mut batch = ctx.sync_batch.clamp(SYNC_BATCH_MIN, SYNC_BATCH_MAX); loop { - let body = sync_folder_items_body(folder, &sync_state, 512); + let retries_before = ctx.client.retries_observed(); + let body = sync_folder_items_body(folder, &sync_state, batch); let resp = match ctx.client.call(ctx.url, "SyncFolderItems", &body) { Ok(r) => r, Err(EwsError::SoapFault { @@ -116,6 +126,15 @@ fn try_sync_folder_items( } } } + if ctx.client.retries_observed() > retries_before { + let shrunk = shrink_sync_batch(batch); + if shrunk != batch { + ctx.logger.warn(&format!( + "EWS SyncFolderItems throttled; shrinking change batch {batch} -> {shrunk}" + )); + batch = shrunk; + } + } sync_state = parsed.sync_state; if !parsed.more { break; @@ -383,6 +402,15 @@ pub fn delete_vanished( mod tests { use super::*; + #[test] + fn sync_batch_shrinks_by_half_down_to_floor() { + assert_eq!(shrink_sync_batch(512), 256); + assert_eq!(shrink_sync_batch(256), 128); + assert_eq!(shrink_sync_batch(64), 32); + assert_eq!(shrink_sync_batch(32), 32); + assert_eq!(shrink_sync_batch(40), 32); + } + #[test] fn diff_delta_only_yields_changes_from_server_response() { use crate::db::exchange_ews_ids::ItemRow; diff --git a/tests/mock_exchange_ews.rs b/tests/mock_exchange_ews.rs index a1363bd..65d451e 100644 --- a/tests/mock_exchange_ews.rs +++ b/tests/mock_exchange_ews.rs @@ -13,7 +13,7 @@ use vandelay::exchange_ews::parse::{ parse_get_attachment_inline, parse_response_messages, parse_sync_folder_items_response, read_envelope_summary, }; -use vandelay::exchange_ews::types::{FolderId, ItemId, ResponseCode}; +use vandelay::exchange_ews::types::{FolderId, ItemId, ResponseCode, ServerVersion}; use vandelay::exchange_ews::xml::{ FolderRef, ItemShape, Traversal, find_folder_body, find_item_body, get_attachment_body, get_item_body, sync_folder_items_body, @@ -503,7 +503,7 @@ fn http_500_with_server_busy_body_is_treated_as_fault_and_retried() { )); let _m1 = server .mock("POST", "/EWS/Exchange.asmx") - .with_status(200) + .with_status(500) .with_header("content-type", TXT_XML) .with_body(busy_body) .expect(1) @@ -527,6 +527,178 @@ fn http_500_with_server_busy_body_is_treated_as_fault_and_retried() { assert!(parsed.folders.is_empty()); } +#[test] +fn invalid_server_version_fault_downgrades_and_succeeds() { + let mut server = mockito::Server::new(); + let url = format!("{}/EWS/Exchange.asmx", server.url()); + let version_fault = format!( + "\ + soap:Server\ + The specified server version is invalid.\ + ErrorInvalidServerVersion\ + " + ); + let ok_body = format!( + "\ + \ + NoError\ + \ + " + ); + + let m_sp1 = server + .mock("POST", "/EWS/Exchange.asmx") + .match_body(Matcher::Regex("Exchange2013_SP1".into())) + .with_status(500) + .with_header("content-type", TXT_XML) + .with_body(&version_fault) + .expect(1) + .create(); + let m_2013 = server + .mock("POST", "/EWS/Exchange.asmx") + .match_body(Matcher::Regex("Exchange2013\"".into())) + .with_status(500) + .with_header("content-type", TXT_XML) + .with_body(&version_fault) + .expect(1) + .create(); + let m_2010 = server + .mock("POST", "/EWS/Exchange.asmx") + .match_body(Matcher::Regex("Exchange2010_SP2".into())) + .with_status(200) + .with_header("content-type", TXT_XML) + .with_body(&ok_body) + .expect_at_least(1) + .create(); + + let c = client(0); + assert_eq!(c.server_version(), ServerVersion::Exchange2013Sp1); + let body = find_folder_body( + FolderRef::Distinguished( + vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot, + ), + Traversal::Deep, + ); + let r = c + .call(&url, "FindFolder", &body) + .expect("call should walk the version ladder down to a version the server accepts"); + let parsed = parse_find_folder_response(&r.body).unwrap(); + assert!(parsed.folders.is_empty()); + assert_eq!(c.server_version(), ServerVersion::Exchange2010Sp2); + assert_eq!(c.retries_observed(), 0); + m_sp1.assert(); + m_2013.assert(); + m_2010.assert(); +} + +#[test] +fn unsupported_version_floor_surfaces_as_soap_fault() { + let mut server = mockito::Server::new(); + let url = format!("{}/EWS/Exchange.asmx", server.url()); + let version_fault = format!( + "\ + soap:Server\ + The specified server version is invalid.\ + ErrorInvalidServerVersion\ + " + ); + let _m = server + .mock("POST", "/EWS/Exchange.asmx") + .with_status(500) + .with_header("content-type", TXT_XML) + .with_body(&version_fault) + .expect_at_least(1) + .create(); + + let c = client(0); + let body = find_folder_body( + FolderRef::Distinguished( + vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot, + ), + Traversal::Deep, + ); + let err = c.call(&url, "FindFolder", &body).unwrap_err(); + assert!( + matches!( + err, + EwsError::SoapFault { + code: ResponseCode::InvalidServerVersion, + .. + } + ), + "got {err:?}" + ); + assert_eq!(c.server_version(), ServerVersion::Exchange2007); +} + +#[test] +fn server_affinity_cookie_is_captured_and_resent() { + let mut server = mockito::Server::new(); + let url = format!("{}/EWS/Exchange.asmx", server.url()); + let ok_body = envelope(&format!( + "\ + NoError\ + \ + " + )); + let first = server + .mock("POST", "/EWS/Exchange.asmx") + .match_header("x-preferserveraffinity", "True") + .match_header("x-backendoverridecookie", Matcher::Missing) + .with_status(200) + .with_header("content-type", TXT_XML) + .with_header( + "set-cookie", + "X-BackEndOverrideCookie=AFFIN123; path=/; HttpOnly", + ) + .with_body(&ok_body) + .expect(1) + .create(); + let second = server + .mock("POST", "/EWS/Exchange.asmx") + .match_header("x-backendoverridecookie", "AFFIN123") + .with_status(200) + .with_header("content-type", TXT_XML) + .with_body(&ok_body) + .expect(1) + .create(); + + let c = client(0); + let body = find_folder_body( + FolderRef::Distinguished( + vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot, + ), + Traversal::Deep, + ); + c.call(&url, "FindFolder", &body).expect("first call ok"); + c.call(&url, "FindFolder", &body).expect("second call ok"); + first.assert(); + second.assert(); +} + +#[test] +fn http_456_surfaces_as_account_locked_auth_error() { + let mut server = mockito::Server::new(); + let url = format!("{}/EWS/Exchange.asmx", server.url()); + let _m = server + .mock("POST", "/EWS/Exchange.asmx") + .with_status(456) + .with_body("Account locked. Unlock at https://unlock.example/") + .create(); + let c = client(0); + let body = find_folder_body( + FolderRef::Distinguished( + vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot, + ), + Traversal::Deep, + ); + let err = c.call(&url, "FindFolder", &body).unwrap_err(); + match err { + EwsError::Auth(m) => assert!(m.contains("locked"), "got {m}"), + other => panic!("expected Auth error, got {other:?}"), + } +} + #[test] fn warning_response_class_is_treated_as_success_in_mock() { let body = envelope(&format!( diff --git a/tests/mock_imap.rs b/tests/mock_imap.rs index 80ab9a5..21dc1b9 100644 --- a/tests/mock_imap.rs +++ b/tests/mock_imap.rs @@ -928,7 +928,10 @@ fn coordinator_special_use_drives_role() { #[test] fn coordinator_omits_special_use_when_unadvertised() { let control: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> { - auth_preamble(conn, "IMAP4rev2 LIST-EXTENDED LIST-STATUS LITERAL+ AUTH=PLAIN")?; + auth_preamble( + conn, + "IMAP4rev2 LIST-EXTENDED LIST-STATUS LITERAL+ AUTH=PLAIN", + )?; let (tag, cmd) = conn.read_command()?; if cmd.contains("SPECIAL-USE") { conn.write_line(&format!(