Fix MS Exchange EWS: add support for version negotiation and other enhancements (closes #6)

This commit is contained in:
Maurus Decimus
2026-06-10 11:43:09 +02:00
parent 49f344660a
commit bf0ef2083b
14 changed files with 888 additions and 99 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. This projec
### Fixed ### Fixed
- IMAP: Import fails with `BAD` on servers that advertise `LIST-EXTENDED` without `SPECIAL-USE`. - 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 ## [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 ### Changed
### Fixed ### 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 ## [1.0.0] - 2026-05-29
Generated
+10 -10
View File
@@ -1999,9 +1999,9 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.3" version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@@ -2022,9 +2022,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.10" version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "ring" name = "ring"
@@ -2930,9 +2930,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.2" version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [ dependencies = [
"sha1_smol", "sha1_smol",
] ]
@@ -3503,18 +3503,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.50" version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.50" version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+1 -1
View File
@@ -37,7 +37,7 @@ mail-parser = "0.11.3"
calcard = "0.3" calcard = "0.3"
rustls = { version = "0.23.40", default-features = false, features = ["aws-lc-rs", "std", "tls12", "logging"] } rustls = { version = "0.23.40", default-features = false, features = ["aws-lc-rs", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7" rustls-platform-verifier = "0.7"
regex = "1.11" regex = "1.12"
flate2 = "1.1.9" flate2 = "1.1.9"
quick-xml = "0.40" quick-xml = "0.40"
chrono = { version = "0.4", default-features = false, features = ["std"] } chrono = { version = "0.4", default-features = false, features = ["std"] }
+202 -10
View File
@@ -141,13 +141,19 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
&mut participants, &mut participants,
&mut next_id, &mut next_id,
&raw.required_attendees, &raw.required_attendees,
true, AttendeeRole::Required,
); );
add_attendees( add_attendees(
&mut participants, &mut participants,
&mut next_id, &mut next_id,
&raw.optional_attendees, &raw.optional_attendees,
false, AttendeeRole::Optional,
);
add_attendees(
&mut participants,
&mut next_id,
&raw.resources,
AttendeeRole::Resource,
); );
if !participants.is_empty() { if !participants.is_empty() {
event.insert("participants".to_owned(), Value::Object(participants)); event.insert("participants".to_owned(), Value::Object(participants));
@@ -165,6 +171,44 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
if let Some(overrides) = overrides { if let Some(overrides) = overrides {
event.insert("recurrenceOverrides".to_owned(), 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 { EventValue {
data: Value::Object(event), data: Value::Object(event),
is_draft: false, is_draft: false,
@@ -172,34 +216,65 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttendeeRole {
Required,
Optional,
Resource,
}
fn add_attendees( fn add_attendees(
out: &mut Map<String, Value>, out: &mut Map<String, Value>,
next_id: &mut u32, next_id: &mut u32,
attendees: &[RawAttendee], attendees: &[RawAttendee],
required: bool, role: AttendeeRole,
) { ) {
for att in attendees { for att in attendees {
let key = next_id.to_string(); let key = next_id.to_string();
*next_id += 1; *next_id += 1;
let mut p = Map::new(); let mut p = Map::new();
p.insert("@type".to_owned(), Value::String("Participant".to_owned())); p.insert("@type".to_owned(), Value::String("Participant".to_owned()));
if let Some(email) = att.email.as_ref() { if let Some(name) = att.name.as_ref() {
p.insert("name".to_owned(), Value::String(name.clone()));
}
match att.email.as_ref() {
Some(email) => {
p.insert( p.insert(
"calendarAddress".to_owned(), "calendarAddress".to_owned(),
Value::String(format!("mailto:{email}")), Value::String(format!("mailto:{email}")),
); );
p.insert("email".to_owned(), Value::String(email.clone())); 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(); let mut roles = Map::new();
if required { match role {
AttendeeRole::Required | AttendeeRole::Resource => {
roles.insert("required".to_owned(), Value::Bool(true)); roles.insert("required".to_owned(), Value::Bool(true));
} else { }
AttendeeRole::Optional => {
roles.insert("optional".to_owned(), Value::Bool(true)); roles.insert("optional".to_owned(), Value::Bool(true));
} }
}
p.insert("roles".to_owned(), Value::Object(roles)); 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() { if let Some(rt) = att.response_type.as_ref() {
let mapped = match rt.as_str() { let mapped = match rt.as_str() {
"Accept" => "accepted", "Accept" => "accepted",
@@ -213,6 +288,8 @@ fn add_attendees(
Value::String(mapped.to_owned()), Value::String(mapped.to_owned()),
); );
} }
}
}
out.insert(key, Value::Object(p)); out.insert(key, Value::Object(p));
} }
} }
@@ -571,6 +648,121 @@ mod tests {
assert_eq!(optional["roles"]["optional"], true); 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] #[test]
fn all_day_span_helper_is_inclusive_of_start() { fn all_day_span_helper_is_inclusive_of_start() {
assert_eq!(all_day_span_days("2025-07-04", "2025-07-04"), 1); assert_eq!(all_day_span_days("2025-07-04", "2025-07-04"), 1);
+154 -51
View File
@@ -16,7 +16,7 @@ use ureq::config::{Config, RedirectAuthHeaders};
use ureq::tls::{RootCerts, TlsConfig}; use ureq::tls::{RootCerts, TlsConfig};
use crate::exchange_ews::error::EwsError; 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::retry::{FaultDisposition, classify_fault, classify_http_status};
use crate::exchange_ews::soap::{EnvelopeOptions, soap_action, wrap_envelope}; use crate::exchange_ews::soap::{EnvelopeOptions, soap_action, wrap_envelope};
use crate::exchange_ews::types::ServerVersion; use crate::exchange_ews::types::ServerVersion;
@@ -32,6 +32,7 @@ struct Inner {
auth: Mutex<Auth>, auth: Mutex<Auth>,
impersonated_smtp: Mutex<Option<String>>, impersonated_smtp: Mutex<Option<String>>,
anchor_mailbox: Mutex<Option<String>>, anchor_mailbox: Mutex<Option<String>>,
affinity_cookie: Mutex<Option<String>>,
retry: RetryPolicy, retry: RetryPolicy,
rate_limit: RateLimitState, rate_limit: RateLimitState,
version: Mutex<ServerVersion>, version: Mutex<ServerVersion>,
@@ -71,6 +72,7 @@ impl EwsClient {
auth: Mutex::new(auth), auth: Mutex::new(auth),
impersonated_smtp: Mutex::new(None), impersonated_smtp: Mutex::new(None),
anchor_mailbox: Mutex::new(None), anchor_mailbox: Mutex::new(None),
affinity_cookie: Mutex::new(None),
retry, retry,
rate_limit: RateLimitState::new(), rate_limit: RateLimitState::new(),
version: Mutex::new(ServerVersion::Exchange2013Sp1), version: Mutex::new(ServerVersion::Exchange2013Sp1),
@@ -142,10 +144,9 @@ impl EwsClient {
} }
pub fn call(&self, url: &str, operation: &str, body: &str) -> Result<SoapResponse, EwsError> { pub fn call(&self, url: &str, operation: &str, body: &str) -> Result<SoapResponse, EwsError> {
let envelope = self.wrap(body);
let action = soap_action(operation); let action = soap_action(operation);
self.inner.soap_calls.fetch_add(1, Ordering::Relaxed); 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 { fn wrap(&self, body: &str) -> String {
@@ -182,22 +183,33 @@ impl EwsClient {
.and_then(|g| g.clone()) .and_then(|g| g.clone())
} }
fn affinity_cookie(&self) -> Option<String> {
self.inner
.affinity_cookie
.lock()
.ok()
.and_then(|g| g.clone())
}
fn execute(&self, url: &str, body: &str, action: &str) -> Result<SoapResponse, EwsError> { fn execute(&self, url: &str, body: &str, action: &str) -> Result<SoapResponse, EwsError> {
let logger = self.logger(); let logger = self.logger();
let policy = self.inner.retry; let policy = self.inner.retry;
let mut attempt: u32 = 0; let mut attempt: u32 = 0;
loop { loop {
self.inner.rate_limit.cooldown().wait(); 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 { match outcome {
AttemptOutcome::Ok { AttemptOutcome::Ok {
status, status,
body: bytes, body: bytes,
retry_after, retry_after,
} => { } => {
let summary = read_envelope_summary(&bytes);
if (200..300).contains(&status) { if (200..300).contains(&status) {
self.inner.rate_limit.on_success(); self.inner.rate_limit.on_success();
match read_envelope_summary(&bytes) { match summary {
Ok(EnvelopeKind::Body { version }) => { Ok(EnvelopeKind::Body { version }) => {
if let Some(v) = version.to_server_version() { if let Some(v) = version.to_server_version() {
self.set_server_version(v); self.set_server_version(v);
@@ -207,53 +219,22 @@ impl EwsClient {
server_version: version.to_server_version(), server_version: version.to_server_version(),
}); });
} }
Ok(EnvelopeKind::Fault { fault, version }) => { Ok(EnvelopeKind::Fault { fault, .. }) => {
if let Some(v) = version.to_server_version() { match self.on_fault(
self.set_server_version(v); &fault,
} sent_version,
match classify_fault(&fault.response_code) {
FaultDisposition::Fatal => {
return Err(EwsError::SoapFault {
code: fault.response_code,
reason: fault.fault_string,
});
}
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, action,
chosen, &policy,
fault.response_code, &logger,
); &mut attempt,
) {
FaultStep::Fail(e) => return Err(e),
FaultStep::Sleep(d) => {
std::thread::sleep(d);
continue;
} }
std::thread::sleep(chosen); FaultStep::Downgrade(v) => {
self.set_server_version(v);
continue; continue;
} }
} }
@@ -261,12 +242,41 @@ impl EwsClient {
Err(e) => return Err(e), 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 { if status == 401 {
return Err(EwsError::Auth(format!( return Err(EwsError::Auth(format!(
"server returned 401: {}", "server returned 401: {}",
truncate(&bytes) 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 { if status == 403 {
return Err(EwsError::HttpStatus { return Err(EwsError::HttpStatus {
status: 403, 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 { fn one_attempt(&self, url: &str, body: &str, action: &str) -> AttemptOutcome {
let mut req = self let mut req = self
.inner .inner
@@ -341,10 +416,14 @@ impl EwsClient {
.header("Accept", "text/xml, application/soap+xml, application/xml") .header("Accept", "text/xml, application/soap+xml, application/xml")
.header("Accept-Encoding", "gzip") .header("Accept-Encoding", "gzip")
.header("SOAPAction", action.to_owned()) .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() { if let Some(anchor) = self.anchor_header() {
req = req.header("X-AnchorMailbox", anchor); 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()); let result = req.send(body.as_bytes());
match result { match result {
Ok(mut resp) => { Ok(mut resp) => {
@@ -354,6 +433,11 @@ impl EwsClient {
.get("retry-after") .get("retry-after")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(retry_after_header); .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() { match resp.body_mut().with_config().limit(MAX_BODY).read_to_vec() {
Ok(bytes) => AttemptOutcome::Ok { Ok(bytes) => AttemptOutcome::Ok {
status, status,
@@ -379,6 +463,25 @@ enum AttemptOutcome {
Transport(EwsError), Transport(EwsError),
} }
enum FaultStep {
Fail(EwsError),
Sleep(Duration),
Downgrade(ServerVersion),
}
fn extract_affinity_cookie(headers: &ureq::http::HeaderMap) -> Option<String> {
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 { fn map_ureq_error(err: ureq::Error) -> EwsError {
match err { match err {
ureq::Error::Io(e) => EwsError::Transport(format!("io: {e}")), ureq::Error::Io(e) => EwsError::Transport(format!("io: {e}")),
+52
View File
@@ -17,6 +17,23 @@ pub fn to_jscontact(raw: &ContactItemRaw) -> Value {
let mut card = Map::new(); let mut card = Map::new();
card.insert("@type".to_owned(), Value::String("Card".to_owned())); card.insert("@type".to_owned(), Value::String("Card".to_owned()));
card.insert("version".to_owned(), Value::String("1.0".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() { if let Some(full) = raw.display_name.as_ref() {
let mut name = Map::new(); let mut name = Map::new();
name.insert("@type".to_owned(), Value::String("Name".to_owned())); name.insert("@type".to_owned(), Value::String("Name".to_owned()));
@@ -411,6 +428,41 @@ mod tests {
assert!(v["onlineServices"]["1"].get("service").is_none()); 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] #[test]
fn unresolvable_relations_are_dropped() { fn unresolvable_relations_are_dropped() {
let raw = ContactItemRaw { let raw = ContactItemRaw {
+137 -2
View File
@@ -1031,6 +1031,13 @@ pub struct CalendarItemRaw {
pub organizer_name: Option<String>, pub organizer_name: Option<String>,
pub required_attendees: Vec<RawAttendee>, pub required_attendees: Vec<RawAttendee>,
pub optional_attendees: Vec<RawAttendee>, pub optional_attendees: Vec<RawAttendee>,
pub resources: Vec<RawAttendee>,
pub reminder_is_set: Option<bool>,
pub reminder_minutes_before_start: Option<i64>,
pub is_online_meeting: Option<bool>,
pub join_online_meeting_url: Option<String>,
pub net_show_url: Option<String>,
pub meeting_workspace_url: Option<String>,
pub categories: Vec<String>, pub categories: Vec<String>,
pub created: Option<String>, pub created: Option<String>,
pub last_modified: Option<String>, pub last_modified: Option<String>,
@@ -1270,6 +1277,20 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result<CalendarItemRaw, EwsError>
attendee_kind = Some("required"); attendee_kind = Some("required");
} else if local.eq_ignore_ascii_case(b"OptionalAttendees") { } else if local.eq_ignore_ascii_case(b"OptionalAttendees") {
attendee_kind = Some("optional"); 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() { } else if local.eq_ignore_ascii_case(b"Attendee") && attendee_kind.is_some() {
current_attendee = Some(RawAttendee::default()); current_attendee = Some(RawAttendee::default());
} else if local.eq_ignore_ascii_case(b"Mailbox") { } else if local.eq_ignore_ascii_case(b"Mailbox") {
@@ -1347,11 +1368,14 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result<CalendarItemRaw, EwsError>
match attendee_kind { match attendee_kind {
Some("required") => item.required_attendees.push(att), Some("required") => item.required_attendees.push(att),
Some("optional") => item.optional_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"organizer" => in_organizer = false,
b"mailbox" => in_mailbox = false, b"mailbox" => in_mailbox = false,
b"categories" => category_collecting = false, b"categories" => category_collecting = false,
@@ -1423,6 +1447,18 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result<CalendarItemRaw, EwsError>
att.response_type = Some(text); 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("category") => item.categories.push(text),
Some("created") => item.created = Some(text), Some("created") => item.created = Some(text),
Some("lastModified") => item.last_modified = Some(text), Some("lastModified") => item.last_modified = Some(text),
@@ -1558,6 +1594,14 @@ pub struct ContactItemRaw {
pub created: Option<String>, pub created: Option<String>,
pub last_modified: Option<String>, pub last_modified: Option<String>,
pub attachments: Vec<RawAttachmentRef>, pub attachments: Vec<RawAttachmentRef>,
pub is_group: bool,
pub members: Vec<RawGroupMember>,
}
#[derive(Debug, Clone, Default)]
pub struct RawGroupMember {
pub name: Option<String>,
pub email: Option<String>,
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -1584,6 +1628,9 @@ pub fn parse_contact_item(inner_xml: &str) -> Result<ContactItemRaw, EwsError> {
let mut current_address: Option<RawContactAddress> = None; let mut current_address: Option<RawContactAddress> = None;
let mut address_text: Option<&'static str> = None; let mut address_text: Option<&'static str> = None;
let mut seen_root_contact = false; let mut seen_root_contact = false;
let mut in_members = false;
let mut member_mailbox = false;
let mut current_member: Option<RawGroupMember> = None;
loop { loop {
buf.clear(); buf.clear();
let (ns, ev) = xml.read_resolved_event_into(&mut buf)?; let (ns, ev) = xml.read_resolved_event_into(&mut buf)?;
@@ -1592,8 +1639,14 @@ pub fn parse_contact_item(inner_xml: &str) -> Result<ContactItemRaw, EwsError> {
Event::Start(ref e) | Event::Empty(ref e) => { Event::Start(ref e) | Event::Empty(ref e) => {
let local = e.local_name().as_ref().to_vec(); let local = e.local_name().as_ref().to_vec();
let is_empty = matches!(ev, Event::Empty(_)); 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; seen_root_contact = true;
if local.eq_ignore_ascii_case(b"DistributionList") {
item.is_group = true;
}
continue; continue;
} }
if ns_kind != Ns::Types { if ns_kind != Ns::Types {
@@ -1646,6 +1699,16 @@ pub fn parse_contact_item(inner_xml: &str) -> Result<ContactItemRaw, EwsError> {
text_target = Some("profession"); text_target = Some("profession");
} else if local.eq_ignore_ascii_case(b"PostalAddressIndex") { } else if local.eq_ignore_ascii_case(b"PostalAddressIndex") {
text_target = Some("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") { } else if local.eq_ignore_ascii_case(b"Body") {
text_target = Some("notes"); text_target = Some("notes");
} else if local.eq_ignore_ascii_case(b"DateTimeCreated") { } else if local.eq_ignore_ascii_case(b"DateTimeCreated") {
@@ -1728,6 +1791,15 @@ pub fn parse_contact_item(inner_xml: &str) -> Result<ContactItemRaw, EwsError> {
b"street" | b"city" | b"state" | b"countryorregion" | b"postalcode" => { b"street" | b"city" | b"state" | b"countryorregion" | b"postalcode" => {
address_text = None; 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; text_target = None;
@@ -1775,6 +1847,16 @@ pub fn parse_contact_item(inner_xml: &str) -> Result<ContactItemRaw, EwsError> {
Some("notes") => item.notes = Some(text), Some("notes") => item.notes = Some(text),
Some("created") => item.created = Some(text), Some("created") => item.created = Some(text),
Some("lastModified") => item.last_modified = 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("category") => item.categories.push(text),
Some("child") => item.children.push(text), Some("child") => item.children.push(text),
Some("company") => item.companies.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!(
"<t:CalendarItem{NS}>\
<t:ItemId Id=\"C1\" ChangeKey=\"K1\"/>\
<t:Subject>Standup</t:Subject>\
<t:UID>uid-r</t:UID>\
<t:ReminderIsSet>true</t:ReminderIsSet>\
<t:ReminderMinutesBeforeStart>10</t:ReminderMinutesBeforeStart>\
<t:Start>2025-06-15T14:00:00Z</t:Start>\
<t:End>2025-06-15T15:00:00Z</t:End>\
<t:IsOnlineMeeting>true</t:IsOnlineMeeting>\
<t:NetShowUrl>https://meet/abc</t:NetShowUrl>\
<t:RequiredAttendees>\
<t:Attendee><t:Mailbox><t:Name>Bob</t:Name><t:EmailAddress>bob@x</t:EmailAddress></t:Mailbox><t:ResponseType>Accept</t:ResponseType></t:Attendee>\
</t:RequiredAttendees>\
<t:Resources>\
<t:Attendee><t:Mailbox><t:Name>Room 7</t:Name><t:EmailAddress>room7@x</t:EmailAddress></t:Mailbox></t:Attendee>\
</t:Resources>\
</t:CalendarItem>"
);
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!(
"<t:DistributionList{NS}>\
<t:ItemId Id=\"DL1\" ChangeKey=\"K1\"/>\
<t:DisplayName>The Team</t:DisplayName>\
<t:Members>\
<t:Member><t:Mailbox><t:Name>Bob</t:Name><t:EmailAddress>bob@x</t:EmailAddress></t:Mailbox><t:Status>Normal</t:Status></t:Member>\
<t:Member><t:Mailbox><t:Name>Carol</t:Name><t:EmailAddress>carol@x</t:EmailAddress></t:Mailbox></t:Member>\
</t:Members>\
</t:DistributionList>"
);
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] #[test]
fn contact_parses_emails_phones_addresses() { fn contact_parses_emails_phones_addresses() {
let body = format!( let body = format!(
+24
View File
@@ -20,6 +20,7 @@ pub fn classify_http_status(status: u16) -> Disposition {
pub enum FaultDisposition { pub enum FaultDisposition {
Fatal, Fatal,
Auth, Auth,
VersionError,
Retryable { delay: Option<Duration> }, Retryable { delay: Option<Duration> },
} }
@@ -35,6 +36,10 @@ pub fn classify_fault(code: &ResponseCode) -> FaultDisposition {
| ResponseCode::AdUnavailable | ResponseCode::AdUnavailable
| ResponseCode::BatchProcessingStopped => FaultDisposition::Retryable { delay: None }, | ResponseCode::BatchProcessingStopped => FaultDisposition::Retryable { delay: None },
ResponseCode::AuthenticationRequired => FaultDisposition::Auth, ResponseCode::AuthenticationRequired => FaultDisposition::Auth,
ResponseCode::InvalidServerVersion
| ResponseCode::IncorrectSchemaVersion
| ResponseCode::InvalidRequest
| ResponseCode::InvalidSchemaVersionForMailboxVersion => FaultDisposition::VersionError,
_ => FaultDisposition::Fatal, _ => FaultDisposition::Fatal,
} }
} }
@@ -65,6 +70,10 @@ pub fn classify_item_response(code: &ResponseCode) -> ItemAction {
| ResponseCode::MimeContentConversionFailed | ResponseCode::MimeContentConversionFailed
| ResponseCode::ImpersonationFailed => ItemAction::Skip, | ResponseCode::ImpersonationFailed => ItemAction::Skip,
ResponseCode::AuthenticationRequired => ItemAction::Skip, ResponseCode::AuthenticationRequired => ItemAction::Skip,
ResponseCode::InvalidServerVersion
| ResponseCode::IncorrectSchemaVersion
| ResponseCode::InvalidRequest
| ResponseCode::InvalidSchemaVersionForMailboxVersion => ItemAction::Skip,
ResponseCode::Other(_) => ItemAction::Skip, ResponseCode::Other(_) => ItemAction::Skip,
} }
} }
@@ -122,6 +131,21 @@ mod tests {
assert_eq!(classify_http_status(401), Disposition::Fatal); 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] #[test]
fn transient_codes_are_retryable_at_item_and_fault_level() { fn transient_codes_are_retryable_at_item_and_fault_level() {
for code in [ for code in [
+53
View File
@@ -133,6 +133,19 @@ impl ServerVersion {
_ => ServerVersion::Exchange2016, _ => ServerVersion::Exchange2016,
} }
} }
pub fn lower(self) -> Option<ServerVersion> {
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -197,6 +210,10 @@ pub enum ResponseCode {
MailboxStoreUnavailable, MailboxStoreUnavailable,
ConnectionFailed, ConnectionFailed,
AdUnavailable, AdUnavailable,
InvalidServerVersion,
IncorrectSchemaVersion,
InvalidRequest,
InvalidSchemaVersionForMailboxVersion,
Other(String), Other(String),
} }
@@ -219,6 +236,12 @@ impl ResponseCode {
"ErrorMailboxStoreUnavailable" => ResponseCode::MailboxStoreUnavailable, "ErrorMailboxStoreUnavailable" => ResponseCode::MailboxStoreUnavailable,
"ErrorConnectionFailed" => ResponseCode::ConnectionFailed, "ErrorConnectionFailed" => ResponseCode::ConnectionFailed,
"ErrorADUnavailable" => ResponseCode::AdUnavailable, "ErrorADUnavailable" => ResponseCode::AdUnavailable,
"ErrorInvalidServerVersion" => ResponseCode::InvalidServerVersion,
"ErrorIncorrectSchemaVersion" => ResponseCode::IncorrectSchemaVersion,
"ErrorInvalidRequest" => ResponseCode::InvalidRequest,
"ErrorInvalidSchemaVersionForMailboxVersion" => {
ResponseCode::InvalidSchemaVersionForMailboxVersion
}
other => ResponseCode::Other(other.to_owned()), other => ResponseCode::Other(other.to_owned()),
} }
} }
@@ -241,6 +264,12 @@ impl ResponseCode {
ResponseCode::MailboxStoreUnavailable => "ErrorMailboxStoreUnavailable", ResponseCode::MailboxStoreUnavailable => "ErrorMailboxStoreUnavailable",
ResponseCode::ConnectionFailed => "ErrorConnectionFailed", ResponseCode::ConnectionFailed => "ErrorConnectionFailed",
ResponseCode::AdUnavailable => "ErrorADUnavailable", ResponseCode::AdUnavailable => "ErrorADUnavailable",
ResponseCode::InvalidServerVersion => "ErrorInvalidServerVersion",
ResponseCode::IncorrectSchemaVersion => "ErrorIncorrectSchemaVersion",
ResponseCode::InvalidRequest => "ErrorInvalidRequest",
ResponseCode::InvalidSchemaVersionForMailboxVersion => {
"ErrorInvalidSchemaVersionForMailboxVersion"
}
ResponseCode::Other(s) => s.as_str(), 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] #[test]
fn response_code_round_trip() { fn response_code_round_trip() {
let codes = [ let codes = [
+25
View File
@@ -89,6 +89,10 @@ pub fn find_item_body(
out.push_str("\" Offset=\""); out.push_str("\" Offset=\"");
out.push_str(&offset.to_string()); out.push_str(&offset.to_string());
out.push_str("\" BasePoint=\"Beginning\"/>"); out.push_str("\" BasePoint=\"Beginning\"/>");
out.push_str(
"<m:SortOrder><t:FieldOrder Order=\"Ascending\">\
<t:FieldURI FieldURI=\"item:DateTimeCreated\"/></t:FieldOrder></m:SortOrder>",
);
out.push_str("<m:ParentFolderIds>"); out.push_str("<m:ParentFolderIds>");
write_folder_ref(&mut out, parent); write_folder_ref(&mut out, parent);
out.push_str("</m:ParentFolderIds></m:FindItem>"); out.push_str("</m:ParentFolderIds></m:FindItem>");
@@ -175,6 +179,12 @@ const CALENDAR_FIELDS: &[&str] = &[
"calendar:Organizer", "calendar:Organizer",
"calendar:RequiredAttendees", "calendar:RequiredAttendees",
"calendar:OptionalAttendees", "calendar:OptionalAttendees",
"calendar:Resources",
"calendar:IsOnlineMeeting",
"calendar:MeetingWorkspaceUrl",
"calendar:NetShowUrl",
"item:ReminderIsSet",
"item:ReminderMinutesBeforeStart",
"calendar:Recurrence", "calendar:Recurrence",
"calendar:ModifiedOccurrences", "calendar:ModifiedOccurrences",
"calendar:DeletedOccurrences", "calendar:DeletedOccurrences",
@@ -274,6 +284,21 @@ mod tests {
assert!(body.contains("<t:FolderId Id=\"FID\" ChangeKey=\"FCK\"/>")); assert!(body.contains("<t:FolderId Id=\"FID\" ChangeKey=\"FCK\"/>"));
} }
#[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("<m:SortOrder>"));
assert!(body.contains("Order=\"Ascending\""));
assert!(body.contains("item:DateTimeCreated"));
let sort_at = body.find("<m:SortOrder>").unwrap();
let parents_at = body.find("<m:ParentFolderIds>").unwrap();
assert!(
sort_at < parents_at,
"SortOrder must precede ParentFolderIds"
);
}
#[test] #[test]
fn get_item_message_shape_requests_mime() { fn get_item_message_shape_requests_mime() {
let ids = vec![ItemId::new("I1", "CK1"), ItemId::new("I2", "")]; let ids = vec![ItemId::new("I1", "CK1"), ItemId::new("I2", "")];
@@ -149,6 +149,7 @@ pub fn run(common: CommonConfig, config: EwsImportConfig) -> Result<Summary, Err
attachment_batch: config.attachment_batch.max(1), attachment_batch: config.attachment_batch.max(1),
connections: config.ews_connections.clamp(1, 8), connections: config.ews_connections.clamp(1, 8),
use_syncfolderitems: config.use_syncfolderitems, use_syncfolderitems: config.use_syncfolderitems,
sync_batch: super::items::SYNC_BATCH_MAX,
logger, logger,
}; };
+29 -1
View File
@@ -26,9 +26,17 @@ pub struct ItemRunCtx<'a> {
pub attachment_batch: usize, pub attachment_batch: usize,
pub connections: usize, pub connections: usize,
pub use_syncfolderitems: bool, pub use_syncfolderitems: bool,
pub sync_batch: u32,
pub logger: Logger, 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)] #[derive(Debug, Clone)]
pub struct EnumeratedItem { pub struct EnumeratedItem {
pub element: String, pub element: String,
@@ -84,8 +92,10 @@ fn try_sync_folder_items(
let mut items: Vec<EnumeratedItem> = Vec::new(); let mut items: Vec<EnumeratedItem> = Vec::new();
let mut deletions: Vec<String> = Vec::new(); let mut deletions: Vec<String> = Vec::new();
let mut iters = 0; let mut iters = 0;
let mut batch = ctx.sync_batch.clamp(SYNC_BATCH_MIN, SYNC_BATCH_MAX);
loop { 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) { let resp = match ctx.client.call(ctx.url, "SyncFolderItems", &body) {
Ok(r) => r, Ok(r) => r,
Err(EwsError::SoapFault { 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; sync_state = parsed.sync_state;
if !parsed.more { if !parsed.more {
break; break;
@@ -383,6 +402,15 @@ pub fn delete_vanished(
mod tests { mod tests {
use super::*; 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] #[test]
fn diff_delta_only_yields_changes_from_server_response() { fn diff_delta_only_yields_changes_from_server_response() {
use crate::db::exchange_ews_ids::ItemRow; use crate::db::exchange_ews_ids::ItemRow;
+174 -2
View File
@@ -13,7 +13,7 @@ use vandelay::exchange_ews::parse::{
parse_get_attachment_inline, parse_response_messages, parse_sync_folder_items_response, parse_get_attachment_inline, parse_response_messages, parse_sync_folder_items_response,
read_envelope_summary, 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::{ use vandelay::exchange_ews::xml::{
FolderRef, ItemShape, Traversal, find_folder_body, find_item_body, get_attachment_body, FolderRef, ItemShape, Traversal, find_folder_body, find_item_body, get_attachment_body,
get_item_body, sync_folder_items_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 let _m1 = server
.mock("POST", "/EWS/Exchange.asmx") .mock("POST", "/EWS/Exchange.asmx")
.with_status(200) .with_status(500)
.with_header("content-type", TXT_XML) .with_header("content-type", TXT_XML)
.with_body(busy_body) .with_body(busy_body)
.expect(1) .expect(1)
@@ -527,6 +527,178 @@ fn http_500_with_server_busy_body_is_treated_as_fault_and_retried() {
assert!(parsed.folders.is_empty()); 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:Envelope{NS}><soap:Body><soap:Fault>\
<faultcode>soap:Server</faultcode>\
<faultstring>The specified server version is invalid.</faultstring>\
<detail><ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorInvalidServerVersion</ResponseCode></detail>\
</soap:Fault></soap:Body></soap:Envelope>"
);
let ok_body = format!(
"<soap:Envelope{NS}><soap:Header><t:ServerVersionInfo MajorVersion=\"14\" MinorVersion=\"3\"/></soap:Header>\
<soap:Body><m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"0\" IncludesLastItemInRange=\"true\"><t:Folders/></m:RootFolder>\
</m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse></soap:Body></soap:Envelope>"
);
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:Envelope{NS}><soap:Body><soap:Fault>\
<faultcode>soap:Server</faultcode>\
<faultstring>The specified server version is invalid.</faultstring>\
<detail><ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorInvalidServerVersion</ResponseCode></detail>\
</soap:Fault></soap:Body></soap:Envelope>"
);
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!(
"<m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"0\" IncludesLastItemInRange=\"true\"><t:Folders/></m:RootFolder>\
</m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>"
));
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] #[test]
fn warning_response_class_is_treated_as_success_in_mock() { fn warning_response_class_is_treated_as_success_in_mock() {
let body = envelope(&format!( let body = envelope(&format!(
+4 -1
View File
@@ -928,7 +928,10 @@ fn coordinator_special_use_drives_role() {
#[test] #[test]
fn coordinator_omits_special_use_when_unadvertised() { fn coordinator_omits_special_use_when_unadvertised() {
let control: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> { 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()?; let (tag, cmd) = conn.read_command()?;
if cmd.contains("SPECIAL-USE") { if cmd.contains("SPECIAL-USE") {
conn.write_line(&format!( conn.write_line(&format!(