Fix MS Exchange EWS: add support for version negotiation and other enhancements (closes #6)
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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"] }
|
||||||
|
|||||||
@@ -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,46 +216,79 @@ 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() {
|
|
||||||
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() {
|
if let Some(name) = att.name.as_ref() {
|
||||||
p.insert("name".to_owned(), Value::String(name.clone()));
|
p.insert("name".to_owned(), Value::String(name.clone()));
|
||||||
}
|
}
|
||||||
let mut roles = Map::new();
|
match att.email.as_ref() {
|
||||||
if required {
|
Some(email) => {
|
||||||
roles.insert("required".to_owned(), Value::Bool(true));
|
p.insert(
|
||||||
} else {
|
"calendarAddress".to_owned(),
|
||||||
roles.insert("optional".to_owned(), Value::Bool(true));
|
Value::String(format!("mailto:{email}")),
|
||||||
}
|
);
|
||||||
p.insert("roles".to_owned(), Value::Object(roles));
|
p.insert("email".to_owned(), Value::String(email.clone()));
|
||||||
if let Some(rt) = att.response_type.as_ref() {
|
let mut roles = Map::new();
|
||||||
let mapped = match rt.as_str() {
|
match role {
|
||||||
"Accept" => "accepted",
|
AttendeeRole::Required | AttendeeRole::Resource => {
|
||||||
"Tentative" => "tentative",
|
roles.insert("required".to_owned(), Value::Bool(true));
|
||||||
"Decline" => "declined",
|
}
|
||||||
"Organizer" => "accepted",
|
AttendeeRole::Optional => {
|
||||||
_ => "needs-action",
|
roles.insert("optional".to_owned(), Value::Bool(true));
|
||||||
};
|
}
|
||||||
p.insert(
|
}
|
||||||
"participationStatus".to_owned(),
|
p.insert("roles".to_owned(), Value::Object(roles));
|
||||||
Value::String(mapped.to_owned()),
|
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));
|
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);
|
||||||
|
|||||||
+155
-52
@@ -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) {
|
action,
|
||||||
FaultDisposition::Fatal => {
|
&policy,
|
||||||
return Err(EwsError::SoapFault {
|
&logger,
|
||||||
code: fault.response_code,
|
&mut attempt,
|
||||||
reason: fault.fault_string,
|
) {
|
||||||
});
|
FaultStep::Fail(e) => return Err(e),
|
||||||
|
FaultStep::Sleep(d) => {
|
||||||
|
std::thread::sleep(d);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
FaultDisposition::Auth => {
|
FaultStep::Downgrade(v) => {
|
||||||
return Err(EwsError::Auth(fault.fault_string));
|
self.set_server_version(v);
|
||||||
}
|
|
||||||
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);
|
|
||||||
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}")),
|
||||||
|
|||||||
@@ -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
@@ -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!(
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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!(
|
||||||
|
|||||||
Reference in New Issue
Block a user