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
+221 -29
View File
@@ -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<String, Value>,
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);
+155 -52
View File
@@ -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<Auth>,
impersonated_smtp: Mutex<Option<String>>,
anchor_mailbox: Mutex<Option<String>>,
affinity_cookie: Mutex<Option<String>>,
retry: RetryPolicy,
rate_limit: RateLimitState,
version: Mutex<ServerVersion>,
@@ -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<SoapResponse, EwsError> {
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<String> {
self.inner
.affinity_cookie
.lock()
.ok()
.and_then(|g| g.clone())
}
fn execute(&self, url: &str, body: &str, action: &str) -> Result<SoapResponse, EwsError> {
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<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 {
match err {
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();
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 {
+137 -2
View File
@@ -1031,6 +1031,13 @@ pub struct CalendarItemRaw {
pub organizer_name: Option<String>,
pub required_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 created: 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");
} 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<CalendarItemRaw, EwsError>
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<CalendarItemRaw, EwsError>
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<String>,
pub last_modified: Option<String>,
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)]
@@ -1584,6 +1628,9 @@ pub fn parse_contact_item(inner_xml: &str) -> Result<ContactItemRaw, EwsError> {
let mut current_address: Option<RawContactAddress> = 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<RawGroupMember> = 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<ContactItemRaw, EwsError> {
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<ContactItemRaw, EwsError> {
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<ContactItemRaw, EwsError> {
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<ContactItemRaw, EwsError> {
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!(
"<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]
fn contact_parses_emails_phones_addresses() {
let body = format!(
+24
View File
@@ -20,6 +20,7 @@ pub fn classify_http_status(status: u16) -> Disposition {
pub enum FaultDisposition {
Fatal,
Auth,
VersionError,
Retryable { delay: Option<Duration> },
}
@@ -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 [
+53
View File
@@ -133,6 +133,19 @@ impl ServerVersion {
_ => 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)]
@@ -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 = [
+25
View File
@@ -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(
"<m:SortOrder><t:FieldOrder Order=\"Ascending\">\
<t:FieldURI FieldURI=\"item:DateTimeCreated\"/></t:FieldOrder></m:SortOrder>",
);
out.push_str("<m:ParentFolderIds>");
write_folder_ref(&mut out, parent);
out.push_str("</m:ParentFolderIds></m:FindItem>");
@@ -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("<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]
fn get_item_message_shape_requests_mime() {
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),
connections: config.ews_connections.clamp(1, 8),
use_syncfolderitems: config.use_syncfolderitems,
sync_batch: super::items::SYNC_BATCH_MAX,
logger,
};
+29 -1
View File
@@ -26,9 +26,17 @@ pub struct ItemRunCtx<'a> {
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<EnumeratedItem> = Vec::new();
let mut deletions: Vec<String> = 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;