Fix MS Exchange EWS: add support for version negotiation and other enhancements (closes #6)
This commit is contained in:
@@ -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
@@ -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}")),
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user