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

This commit is contained in:
Maurus Decimus
2026-06-10 11:43:09 +02:00
parent 49f344660a
commit bf0ef2083b
14 changed files with 888 additions and 99 deletions
+221 -29
View File
@@ -141,13 +141,19 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
&mut participants,
&mut next_id,
&raw.required_attendees,
true,
AttendeeRole::Required,
);
add_attendees(
&mut participants,
&mut next_id,
&raw.optional_attendees,
false,
AttendeeRole::Optional,
);
add_attendees(
&mut participants,
&mut next_id,
&raw.resources,
AttendeeRole::Resource,
);
if !participants.is_empty() {
event.insert("participants".to_owned(), Value::Object(participants));
@@ -165,6 +171,44 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
if let Some(overrides) = overrides {
event.insert("recurrenceOverrides".to_owned(), overrides);
}
if raw.reminder_is_set == Some(true) {
let minutes = raw.reminder_minutes_before_start.unwrap_or(0).max(0);
let offset = if minutes == 0 {
"PT0S".to_owned()
} else {
format!("-PT{minutes}M")
};
let alert = json!({
"@type": "Alert",
"trigger": {"@type": "OffsetTrigger", "offset": offset, "relativeTo": "start"},
"action": "display",
});
event.insert(
"alerts".to_owned(),
Value::Object(map_singleton("1", alert)),
);
}
if let Some(url) = raw
.join_online_meeting_url
.as_deref()
.or(raw.net_show_url.as_deref())
.or(raw.meeting_workspace_url.as_deref())
.filter(|s| !s.is_empty())
{
let mut vl = Map::new();
vl.insert(
"@type".to_owned(),
Value::String("VirtualLocation".to_owned()),
);
vl.insert("uri".to_owned(), Value::String(url.to_owned()));
if raw.is_online_meeting == Some(true) {
vl.insert("features".to_owned(), json!({"video": true}));
}
event.insert(
"virtualLocations".to_owned(),
Value::Object(map_singleton("1", Value::Object(vl))),
);
}
EventValue {
data: Value::Object(event),
is_draft: false,
@@ -172,46 +216,79 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttendeeRole {
Required,
Optional,
Resource,
}
fn add_attendees(
out: &mut Map<String, Value>,
next_id: &mut u32,
attendees: &[RawAttendee],
required: bool,
role: AttendeeRole,
) {
for att in attendees {
let key = next_id.to_string();
*next_id += 1;
let mut p = Map::new();
p.insert("@type".to_owned(), Value::String("Participant".to_owned()));
if let Some(email) = att.email.as_ref() {
p.insert(
"calendarAddress".to_owned(),
Value::String(format!("mailto:{email}")),
);
p.insert("email".to_owned(), Value::String(email.clone()));
}
if let Some(name) = att.name.as_ref() {
p.insert("name".to_owned(), Value::String(name.clone()));
}
let mut roles = Map::new();
if required {
roles.insert("required".to_owned(), Value::Bool(true));
} else {
roles.insert("optional".to_owned(), Value::Bool(true));
}
p.insert("roles".to_owned(), Value::Object(roles));
if let Some(rt) = att.response_type.as_ref() {
let mapped = match rt.as_str() {
"Accept" => "accepted",
"Tentative" => "tentative",
"Decline" => "declined",
"Organizer" => "accepted",
_ => "needs-action",
};
p.insert(
"participationStatus".to_owned(),
Value::String(mapped.to_owned()),
);
match att.email.as_ref() {
Some(email) => {
p.insert(
"calendarAddress".to_owned(),
Value::String(format!("mailto:{email}")),
);
p.insert("email".to_owned(), Value::String(email.clone()));
let mut roles = Map::new();
match role {
AttendeeRole::Required | AttendeeRole::Resource => {
roles.insert("required".to_owned(), Value::Bool(true));
}
AttendeeRole::Optional => {
roles.insert("optional".to_owned(), Value::Bool(true));
}
}
p.insert("roles".to_owned(), Value::Object(roles));
if role == AttendeeRole::Resource {
p.insert("kind".to_owned(), Value::String("resource".to_owned()));
} else {
p.insert("expectReply".to_owned(), Value::Bool(true));
}
if let Some(rt) = att.response_type.as_ref() {
let mapped = match rt.as_str() {
"Accept" => "accepted",
"Tentative" => "tentative",
"Decline" => "declined",
"Organizer" => "accepted",
"NoResponseReceived" => "needs-action",
_ => "needs-action",
};
p.insert(
"participationStatus".to_owned(),
Value::String(mapped.to_owned()),
);
}
}
None => {
if let Some(rt) = att.response_type.as_ref() {
let mapped = match rt.as_str() {
"Accept" => "accepted",
"Tentative" => "tentative",
"Decline" => "declined",
"Organizer" => "accepted",
_ => "needs-action",
};
p.insert(
"participationStatus".to_owned(),
Value::String(mapped.to_owned()),
);
}
}
}
out.insert(key, Value::Object(p));
}
@@ -571,6 +648,121 @@ mod tests {
assert_eq!(optional["roles"]["optional"], true);
}
#[test]
fn reminder_becomes_offset_trigger_alert() {
let raw = CalendarItemRaw {
uid: Some("uid-alarm".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
reminder_is_set: Some(true),
reminder_minutes_before_start: Some(15),
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
let alert = &v["alerts"]["1"];
assert_eq!(alert["@type"], "Alert");
assert_eq!(alert["action"], "display");
assert_eq!(alert["trigger"]["@type"], "OffsetTrigger");
assert_eq!(alert["trigger"]["offset"], "-PT15M");
assert_eq!(alert["trigger"]["relativeTo"], "start");
}
#[test]
fn reminder_not_set_emits_no_alerts() {
let raw = CalendarItemRaw {
uid: Some("uid-noalarm".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
reminder_is_set: Some(false),
reminder_minutes_before_start: Some(15),
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
assert!(v.get("alerts").is_none());
}
#[test]
fn online_meeting_becomes_virtual_location() {
let raw = CalendarItemRaw {
uid: Some("uid-online".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
is_online_meeting: Some(true),
net_show_url: Some("https://teams.example/join/abc".to_owned()),
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
let vl = &v["virtualLocations"]["1"];
assert_eq!(vl["@type"], "VirtualLocation");
assert_eq!(vl["uri"], "https://teams.example/join/abc");
assert_eq!(vl["features"]["video"], true);
}
#[test]
fn join_url_preferred_over_workspace_url() {
let raw = CalendarItemRaw {
uid: Some("uid-join".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
join_online_meeting_url: Some("https://join/primary".to_owned()),
meeting_workspace_url: Some("https://workspace/secondary".to_owned()),
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
assert_eq!(v["virtualLocations"]["1"]["uri"], "https://join/primary");
}
#[test]
fn resources_get_resource_kind_and_no_expect_reply() {
let raw = CalendarItemRaw {
uid: Some("uid-res".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
resources: vec![crate::exchange_ews::parse::RawAttendee {
email: Some("room-7@x".to_owned()),
name: Some("Room 7".to_owned()),
response_type: Some("Accept".to_owned()),
}],
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
let res = v["participants"]
.as_object()
.unwrap()
.values()
.find(|p| p["email"] == "room-7@x")
.unwrap();
assert_eq!(res["kind"], "resource");
assert_eq!(res["roles"]["required"], true);
assert_eq!(res["participationStatus"], "accepted");
assert!(res.get("expectReply").is_none());
}
#[test]
fn attendees_carry_expect_reply_and_participation_status() {
let raw = CalendarItemRaw {
uid: Some("uid-rsvp".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
required_attendees: vec![crate::exchange_ews::parse::RawAttendee {
email: Some("bob@x".to_owned()),
name: None,
response_type: Some("Tentative".to_owned()),
}],
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
let bob = v["participants"]
.as_object()
.unwrap()
.values()
.find(|p| p["email"] == "bob@x")
.unwrap();
assert_eq!(bob["expectReply"], true);
assert_eq!(bob["participationStatus"], "tentative");
assert_eq!(bob["roles"]["required"], true);
}
#[test]
fn all_day_span_helper_is_inclusive_of_start() {
assert_eq!(all_day_span_days("2025-07-04", "2025-07-04"), 1);