EWS fixes - part 3
This commit is contained in:
@@ -17,6 +17,30 @@ pub struct EventValue {
|
||||
}
|
||||
|
||||
pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
to_jscalendar_with_exceptions(raw, &[])
|
||||
}
|
||||
|
||||
pub fn to_jscalendar_with_exceptions(
|
||||
raw: &CalendarItemRaw,
|
||||
modified_full: &[CalendarItemRaw],
|
||||
) -> EventValue {
|
||||
let iana = raw
|
||||
.start_tz
|
||||
.as_deref()
|
||||
.map(|tz| resolve_to_iana(tz).unwrap_or_else(|| "Etc/UTC".to_owned()));
|
||||
let mut event = build_event_map(raw, iana.as_deref());
|
||||
if let Some(overrides) = build_recurrence_overrides(&event, raw, modified_full, iana.as_deref())
|
||||
{
|
||||
event.insert("recurrenceOverrides".to_owned(), overrides);
|
||||
}
|
||||
EventValue {
|
||||
data: Value::Object(event),
|
||||
is_draft: false,
|
||||
use_default_alerts: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_event_map(raw: &CalendarItemRaw, iana: Option<&str>) -> Map<String, Value> {
|
||||
let mut event = Map::new();
|
||||
event.insert("@type".to_owned(), Value::String("Event".to_owned()));
|
||||
if let Some(uid) = raw.uid.as_ref() {
|
||||
@@ -57,7 +81,7 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
Value::String(normalise_utc_datetime(created)),
|
||||
);
|
||||
}
|
||||
if let Some(updated) = raw.last_modified.as_ref() {
|
||||
if let Some(updated) = raw.last_modified.as_ref().or(raw.created.as_ref()) {
|
||||
event.insert(
|
||||
"updated".to_owned(),
|
||||
Value::String(normalise_utc_datetime(updated)),
|
||||
@@ -70,10 +94,6 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
}
|
||||
event.insert("keywords".to_owned(), Value::Object(kw));
|
||||
}
|
||||
let iana = raw
|
||||
.start_tz
|
||||
.as_deref()
|
||||
.map(|tz| resolve_to_iana(tz).unwrap_or_else(|| "Etc/UTC".to_owned()));
|
||||
if let Some(true) = raw.is_all_day_event {
|
||||
if let Some(start) = raw.start.as_ref() {
|
||||
let date_only = start.split('T').next().unwrap_or(start.as_str());
|
||||
@@ -92,7 +112,7 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
if let Some(start) = raw.start.as_ref() {
|
||||
event.insert(
|
||||
"start".to_owned(),
|
||||
Value::String(to_local_datetime_in(start, iana.as_deref())),
|
||||
Value::String(to_local_datetime_in(start, iana)),
|
||||
);
|
||||
}
|
||||
if let (Some(start), Some(end)) = (raw.start.as_ref(), raw.end.as_ref())
|
||||
@@ -100,7 +120,7 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
{
|
||||
event.insert("duration".to_owned(), Value::String(dur));
|
||||
}
|
||||
if let Some(tz) = iana.as_deref() {
|
||||
if let Some(tz) = iana {
|
||||
event.insert("timeZone".to_owned(), Value::String(tz.to_owned()));
|
||||
}
|
||||
}
|
||||
@@ -116,10 +136,13 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
}
|
||||
let mut participants = Map::new();
|
||||
let mut next_id = 1;
|
||||
if let Some(email) = raw.organizer_smtp.as_ref() {
|
||||
if let Some((cal_addr, smtp)) = resolve_calendar_address(
|
||||
raw.organizer_smtp.as_deref(),
|
||||
raw.organizer_routing_type.as_deref(),
|
||||
raw.organizer_name.as_deref(),
|
||||
) {
|
||||
let key = next_id.to_string();
|
||||
next_id += 1;
|
||||
let cal_addr = format!("mailto:{email}");
|
||||
event.insert(
|
||||
"organizerCalendarAddress".to_owned(),
|
||||
Value::String(cal_addr.clone()),
|
||||
@@ -127,7 +150,9 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
let mut p = Map::new();
|
||||
p.insert("@type".to_owned(), Value::String("Participant".to_owned()));
|
||||
p.insert("calendarAddress".to_owned(), Value::String(cal_addr));
|
||||
p.insert("email".to_owned(), Value::String(email.clone()));
|
||||
if let Some(email) = smtp {
|
||||
p.insert("email".to_owned(), Value::String(email));
|
||||
}
|
||||
let mut roles = Map::new();
|
||||
roles.insert("owner".to_owned(), Value::Bool(true));
|
||||
roles.insert("chair".to_owned(), Value::Bool(true));
|
||||
@@ -163,14 +188,6 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
{
|
||||
event.insert("recurrenceRule".to_owned(), rule);
|
||||
}
|
||||
let overrides = build_recurrence_overrides(
|
||||
&raw.modified_occurrences,
|
||||
&raw.deleted_occurrences,
|
||||
iana.as_deref(),
|
||||
);
|
||||
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 {
|
||||
@@ -209,11 +226,33 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
Value::Object(map_singleton("1", Value::Object(vl))),
|
||||
);
|
||||
}
|
||||
EventValue {
|
||||
data: Value::Object(event),
|
||||
is_draft: false,
|
||||
use_default_alerts: false,
|
||||
event
|
||||
}
|
||||
|
||||
fn synthetic_attendee_address(identifier: &str) -> String {
|
||||
format!(
|
||||
"urn:x-vandelay:attendee:{}",
|
||||
blake3::hash(identifier.as_bytes()).to_hex()
|
||||
)
|
||||
}
|
||||
|
||||
fn is_smtp_routing(routing_type: Option<&str>) -> bool {
|
||||
routing_type.is_none_or(|rt| rt.eq_ignore_ascii_case("SMTP"))
|
||||
}
|
||||
|
||||
fn resolve_calendar_address(
|
||||
address: Option<&str>,
|
||||
routing_type: Option<&str>,
|
||||
name: Option<&str>,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
if let Some(addr) = address.filter(|a| !a.trim().is_empty()) {
|
||||
if is_smtp_routing(routing_type) {
|
||||
return Some((format!("mailto:{addr}"), Some(addr.to_owned())));
|
||||
}
|
||||
return Some((synthetic_attendee_address(addr), None));
|
||||
}
|
||||
name.filter(|n| !n.trim().is_empty())
|
||||
.map(|n| (synthetic_attendee_address(n), None))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -237,73 +276,77 @@ fn add_attendees(
|
||||
if let Some(name) = att.name.as_ref() {
|
||||
p.insert("name".to_owned(), Value::String(name.clone()));
|
||||
}
|
||||
match att.email.as_ref() {
|
||||
Some(email) => {
|
||||
p.insert(
|
||||
"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));
|
||||
}
|
||||
let calendar_address = resolve_calendar_address(
|
||||
att.email.as_deref(),
|
||||
att.routing_type.as_deref(),
|
||||
att.name.as_deref(),
|
||||
);
|
||||
if let Some((addr, smtp)) = calendar_address {
|
||||
p.insert("calendarAddress".to_owned(), Value::String(addr));
|
||||
if let Some(email) = smtp {
|
||||
p.insert("email".to_owned(), Value::String(email));
|
||||
}
|
||||
let mut roles = Map::new();
|
||||
match role {
|
||||
AttendeeRole::Required | AttendeeRole::Resource => {
|
||||
roles.insert("required".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()),
|
||||
);
|
||||
AttendeeRole::Optional => {
|
||||
roles.insert("optional".to_owned(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
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()),
|
||||
);
|
||||
}
|
||||
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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
out.insert(key, Value::Object(p));
|
||||
}
|
||||
}
|
||||
|
||||
const OVERRIDE_IGNORED_POINTERS: &[&str] = &[
|
||||
"@type",
|
||||
"uid",
|
||||
"recurrenceRule",
|
||||
"recurrenceOverrides",
|
||||
"recurrenceId",
|
||||
"recurrenceIdTimeZone",
|
||||
"method",
|
||||
"organizerCalendarAddress",
|
||||
"privacy",
|
||||
"prodId",
|
||||
"relatedTo",
|
||||
"created",
|
||||
"updated",
|
||||
];
|
||||
|
||||
fn build_recurrence_overrides(
|
||||
modified: &[RawOccurrence],
|
||||
deleted: &[RawOccurrence],
|
||||
base_event: &Map<String, Value>,
|
||||
raw: &CalendarItemRaw,
|
||||
modified_full: &[CalendarItemRaw],
|
||||
iana: Option<&str>,
|
||||
) -> Option<Value> {
|
||||
if modified.is_empty() && deleted.is_empty() {
|
||||
if raw.modified_occurrences.is_empty() && raw.deleted_occurrences.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut map = Map::new();
|
||||
for occ in modified {
|
||||
for occ in &raw.modified_occurrences {
|
||||
let Some(key) = occ
|
||||
.original_start
|
||||
.as_deref()
|
||||
@@ -312,21 +355,19 @@ fn build_recurrence_overrides(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut o = Map::new();
|
||||
if let Some(s) = occ.start.as_ref() {
|
||||
o.insert(
|
||||
"start".to_owned(),
|
||||
Value::String(to_local_datetime_in(s, iana)),
|
||||
);
|
||||
}
|
||||
if let (Some(s), Some(e)) = (occ.start.as_ref(), occ.end.as_ref())
|
||||
&& let Some(dur) = duration_iso8601(s, e)
|
||||
{
|
||||
o.insert("duration".to_owned(), Value::String(dur));
|
||||
}
|
||||
map.insert(key, Value::Object(o));
|
||||
let full = modified_full
|
||||
.iter()
|
||||
.find(|f| !occ.item_id.id.is_empty() && f.id.id == occ.item_id.id);
|
||||
let patch = match full {
|
||||
Some(full) => {
|
||||
let occ_event = build_event_map(full, iana);
|
||||
override_patch(&occ_event, base_event, &key)
|
||||
}
|
||||
None => time_only_patch(occ, iana),
|
||||
};
|
||||
map.insert(key, Value::Object(patch));
|
||||
}
|
||||
for occ in deleted {
|
||||
for occ in &raw.deleted_occurrences {
|
||||
let Some(key) = occ.start.as_deref().map(|s| to_local_datetime_in(s, iana)) else {
|
||||
continue;
|
||||
};
|
||||
@@ -339,6 +380,53 @@ fn build_recurrence_overrides(
|
||||
}
|
||||
}
|
||||
|
||||
fn override_patch(
|
||||
occ_event: &Map<String, Value>,
|
||||
base_event: &Map<String, Value>,
|
||||
recurrence_id: &str,
|
||||
) -> Map<String, Value> {
|
||||
let mut patch = Map::new();
|
||||
let inherited_start = Value::String(recurrence_id.to_owned());
|
||||
for (k, v) in occ_event {
|
||||
if OVERRIDE_IGNORED_POINTERS.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let baseline = if k == "start" {
|
||||
Some(&inherited_start)
|
||||
} else {
|
||||
base_event.get(k)
|
||||
};
|
||||
if baseline != Some(v) {
|
||||
patch.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
for k in base_event.keys() {
|
||||
if k == "start" || OVERRIDE_IGNORED_POINTERS.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !occ_event.contains_key(k) {
|
||||
patch.insert(k.clone(), Value::Null);
|
||||
}
|
||||
}
|
||||
patch
|
||||
}
|
||||
|
||||
fn time_only_patch(occ: &RawOccurrence, iana: Option<&str>) -> Map<String, Value> {
|
||||
let mut o = Map::new();
|
||||
if let Some(s) = occ.start.as_ref() {
|
||||
o.insert(
|
||||
"start".to_owned(),
|
||||
Value::String(to_local_datetime_in(s, iana)),
|
||||
);
|
||||
}
|
||||
if let (Some(s), Some(e)) = (occ.start.as_ref(), occ.end.as_ref())
|
||||
&& let Some(dur) = duration_iso8601(s, e)
|
||||
{
|
||||
o.insert("duration".to_owned(), Value::String(dur));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn normalise_utc_datetime(s: &str) -> String {
|
||||
let trimmed = s.trim();
|
||||
let stripped = trimmed
|
||||
@@ -599,6 +687,182 @@ mod tests {
|
||||
assert_eq!(over["2025-06-17T14:00:00"]["excluded"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_backfills_from_created_when_last_modified_absent() {
|
||||
let raw = CalendarItemRaw {
|
||||
uid: Some("u".to_owned()),
|
||||
start: Some("2025-06-15T14:00:00Z".to_owned()),
|
||||
end: Some("2025-06-15T15:00:00Z".to_owned()),
|
||||
created: Some("2025-06-01T09:00:00Z".to_owned()),
|
||||
last_modified: None,
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let v = to_jscalendar(&raw).data;
|
||||
assert_eq!(
|
||||
v["updated"], "2025-06-01T09:00:00Z",
|
||||
"updated is mandatory (jscalendarbis 3.1.6); backfill from created"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_less_attendee_keeps_rsvp_via_synthetic_non_mailto_calendar_address() {
|
||||
let raw = CalendarItemRaw {
|
||||
uid: Some("u".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: None,
|
||||
routing_type: None,
|
||||
name: Some("No Address Person".to_owned()),
|
||||
response_type: Some("Accept".to_owned()),
|
||||
}],
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let v = to_jscalendar(&raw).data;
|
||||
let p = v["participants"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|p| p["name"] == "No Address Person")
|
||||
.unwrap();
|
||||
let addr = p["calendarAddress"].as_str().unwrap();
|
||||
assert!(
|
||||
addr.starts_with("urn:x-vandelay:attendee:"),
|
||||
"a name-only attendee gets a stable synthetic calendarAddress, got {addr}"
|
||||
);
|
||||
assert!(
|
||||
!addr.starts_with("mailto:"),
|
||||
"synthetic address MUST NOT be a mailto: (export must not invite a fabricated address)"
|
||||
);
|
||||
assert_eq!(
|
||||
p["participationStatus"], "accepted",
|
||||
"the RSVP is preserved now that a calendarAddress is present (jscalendarbis 3.4.6)"
|
||||
);
|
||||
assert!(p.get("email").is_none(), "no real email is invented");
|
||||
assert_eq!(p["roles"]["required"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_attendee_address_is_stable_per_name() {
|
||||
assert_eq!(
|
||||
synthetic_attendee_address("Jane Doe"),
|
||||
synthetic_attendee_address("Jane Doe")
|
||||
);
|
||||
assert_ne!(
|
||||
synthetic_attendee_address("Jane Doe"),
|
||||
synthetic_attendee_address("John Doe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_ex_routing_type_does_not_produce_a_mailto() {
|
||||
let dn = "/o=ExchangeLabs/ou=Exchange Administrative Group/cn=Recipients/cn=abc123";
|
||||
let raw = CalendarItemRaw {
|
||||
uid: Some("u".to_owned()),
|
||||
start: Some("2025-06-15T14:00:00Z".to_owned()),
|
||||
end: Some("2025-06-15T15:00:00Z".to_owned()),
|
||||
organizer_smtp: Some(dn.to_owned()),
|
||||
organizer_routing_type: Some("EX".to_owned()),
|
||||
organizer_name: Some("Legacy Organizer".to_owned()),
|
||||
required_attendees: vec![RawAttendee {
|
||||
email: Some(dn.to_owned()),
|
||||
routing_type: Some("EX".to_owned()),
|
||||
name: Some("Legacy Attendee".to_owned()),
|
||||
response_type: Some("Accept".to_owned()),
|
||||
}],
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let v = to_jscalendar(&raw).data;
|
||||
let org_addr = v["organizerCalendarAddress"].as_str().unwrap();
|
||||
assert!(
|
||||
org_addr.starts_with("urn:x-vandelay:attendee:") && !org_addr.contains("mailto:"),
|
||||
"an EX organizer must not become mailto:/o=.../cn=...; got {org_addr}"
|
||||
);
|
||||
let att = v["participants"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|p| p["name"] == "Legacy Attendee")
|
||||
.unwrap();
|
||||
let att_addr = att["calendarAddress"].as_str().unwrap();
|
||||
assert!(!att_addr.starts_with("mailto:"), "got {att_addr}");
|
||||
assert!(att.get("email").is_none(), "an X500 DN is not an email");
|
||||
assert_eq!(att["participationStatus"], "accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_routing_type_still_yields_mailto() {
|
||||
let raw = CalendarItemRaw {
|
||||
uid: Some("u".to_owned()),
|
||||
start: Some("2025-06-15T14:00:00Z".to_owned()),
|
||||
end: Some("2025-06-15T15:00:00Z".to_owned()),
|
||||
required_attendees: vec![RawAttendee {
|
||||
email: Some("[email protected]".to_owned()),
|
||||
routing_type: Some("SMTP".to_owned()),
|
||||
name: None,
|
||||
response_type: Some("Accept".to_owned()),
|
||||
}],
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let v = to_jscalendar(&raw).data;
|
||||
let att = v["participants"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!(att["calendarAddress"], "mailto:[email protected]");
|
||||
assert_eq!(att["email"], "[email protected]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_occurrence_override_captures_title_and_location_not_just_time() {
|
||||
let master = CalendarItemRaw {
|
||||
uid: Some("uid-series".to_owned()),
|
||||
subject: Some("Standup".to_owned()),
|
||||
start: Some("2025-06-16T14:00:00Z".to_owned()),
|
||||
end: Some("2025-06-16T14:30:00Z".to_owned()),
|
||||
recurrence: Some(crate::exchange_ews::parse::RawRecurrence {
|
||||
pattern: Some(RecurrencePattern::Daily { interval: 1 }),
|
||||
range: Some(RecurrenceRange::Numbered {
|
||||
start_date: "2025-06-16".to_owned(),
|
||||
number_of_occurrences: 5,
|
||||
}),
|
||||
}),
|
||||
modified_occurrences: vec![RawOccurrence {
|
||||
item_id: crate::exchange_ews::types::ItemId::new("EXC1", ""),
|
||||
start: Some("2025-06-18T14:00:00Z".to_owned()),
|
||||
end: Some("2025-06-18T14:30:00Z".to_owned()),
|
||||
original_start: Some("2025-06-18T14:00:00Z".to_owned()),
|
||||
}],
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let full_occ = CalendarItemRaw {
|
||||
id: crate::exchange_ews::types::ItemId::new("EXC1", ""),
|
||||
uid: Some("uid-series".to_owned()),
|
||||
subject: Some("Sprint Retro".to_owned()),
|
||||
location: Some("Big Room".to_owned()),
|
||||
start: Some("2025-06-18T14:00:00Z".to_owned()),
|
||||
end: Some("2025-06-18T14:30:00Z".to_owned()),
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let v = to_jscalendar_with_exceptions(&master, std::slice::from_ref(&full_occ)).data;
|
||||
let ov = &v["recurrenceOverrides"]["2025-06-18T14:00:00"];
|
||||
assert_eq!(
|
||||
ov["title"], "Sprint Retro",
|
||||
"changed subject must be in the override"
|
||||
);
|
||||
assert_eq!(ov["locations"]["1"]["name"], "Big Room");
|
||||
assert!(
|
||||
ov.get("start").is_none(),
|
||||
"an unchanged occurrence time must not emit a redundant start patch"
|
||||
);
|
||||
assert!(
|
||||
ov.get("uid").is_none() && ov.get("recurrenceRule").is_none(),
|
||||
"ignored pointers must never appear in a PatchObject"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_day_all_day_spans_correct_number_of_days() {
|
||||
let raw = CalendarItemRaw {
|
||||
@@ -622,11 +886,13 @@ mod tests {
|
||||
organizer_smtp: Some("alice@x".to_owned()),
|
||||
required_attendees: vec![crate::exchange_ews::parse::RawAttendee {
|
||||
email: Some("bob@x".to_owned()),
|
||||
routing_type: None,
|
||||
name: None,
|
||||
response_type: Some("Accept".to_owned()),
|
||||
}],
|
||||
optional_attendees: vec![crate::exchange_ews::parse::RawAttendee {
|
||||
email: Some("eve@x".to_owned()),
|
||||
routing_type: None,
|
||||
name: None,
|
||||
response_type: None,
|
||||
}],
|
||||
@@ -720,6 +986,7 @@ mod tests {
|
||||
end: Some("2025-06-15T15:00:00Z".to_owned()),
|
||||
resources: vec![crate::exchange_ews::parse::RawAttendee {
|
||||
email: Some("room-7@x".to_owned()),
|
||||
routing_type: None,
|
||||
name: Some("Room 7".to_owned()),
|
||||
response_type: Some("Accept".to_owned()),
|
||||
}],
|
||||
@@ -746,6 +1013,7 @@ mod tests {
|
||||
end: Some("2025-06-15T15:00:00Z".to_owned()),
|
||||
required_attendees: vec![crate::exchange_ews::parse::RawAttendee {
|
||||
email: Some("bob@x".to_owned()),
|
||||
routing_type: None,
|
||||
name: None,
|
||||
response_type: Some("Tentative".to_owned()),
|
||||
}],
|
||||
|
||||
@@ -1089,6 +1089,7 @@ pub struct CalendarItemRaw {
|
||||
pub modified_occurrences: Vec<RawOccurrence>,
|
||||
pub deleted_occurrences: Vec<RawOccurrence>,
|
||||
pub organizer_smtp: Option<String>,
|
||||
pub organizer_routing_type: Option<String>,
|
||||
pub organizer_name: Option<String>,
|
||||
pub required_attendees: Vec<RawAttendee>,
|
||||
pub optional_attendees: Vec<RawAttendee>,
|
||||
@@ -1118,6 +1119,7 @@ pub struct RawOccurrence {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RawAttendee {
|
||||
pub email: Option<String>,
|
||||
pub routing_type: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub response_type: Option<String>,
|
||||
}
|
||||
@@ -1370,6 +1372,12 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result<CalendarItemRaw, EwsError>
|
||||
} else {
|
||||
"attendeeEmail"
|
||||
});
|
||||
} else if local.eq_ignore_ascii_case(b"RoutingType") && in_mailbox {
|
||||
text_target = Some(if in_organizer {
|
||||
"organizerRouting"
|
||||
} else {
|
||||
"attendeeRouting"
|
||||
});
|
||||
} else if local.eq_ignore_ascii_case(b"ResponseType") && current_attendee.is_some()
|
||||
{
|
||||
text_target = Some("attendeeResponse");
|
||||
@@ -1432,6 +1440,7 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result<CalendarItemRaw, EwsError>
|
||||
"recurrenceId" => item.recurrence_id = Some(text),
|
||||
"organizerName" => item.organizer_name = Some(text),
|
||||
"organizerEmail" => item.organizer_smtp = Some(text),
|
||||
"organizerRouting" => item.organizer_routing_type = Some(text),
|
||||
"attendeeName" => {
|
||||
if let Some(att) = current_attendee.as_mut() {
|
||||
att.name = Some(text);
|
||||
@@ -1442,6 +1451,11 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result<CalendarItemRaw, EwsError>
|
||||
att.email = Some(text);
|
||||
}
|
||||
}
|
||||
"attendeeRouting" => {
|
||||
if let Some(att) = current_attendee.as_mut() {
|
||||
att.routing_type = Some(text);
|
||||
}
|
||||
}
|
||||
"attendeeResponse" => {
|
||||
if let Some(att) = current_attendee.as_mut() {
|
||||
att.response_type = Some(text);
|
||||
@@ -2405,6 +2419,104 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_date_recurrence_wire_shape_with_trailing_z_yields_clean_until() {
|
||||
let body = format!(
|
||||
"<t:CalendarItem{NS}><t:ItemId Id=\"M\" ChangeKey=\"K\"/>\
|
||||
<t:Subject>RecDailyEnd</t:Subject><t:UID>u</t:UID>\
|
||||
<t:Start>2026-07-01T15:00:00Z</t:Start><t:End>2026-07-01T15:30:00Z</t:End>\
|
||||
<t:CalendarItemType>RecurringMaster</t:CalendarItemType>\
|
||||
<t:Recurrence>\
|
||||
<t:DailyRecurrence><t:Interval>2</t:Interval></t:DailyRecurrence>\
|
||||
<t:EndDateRecurrence><t:StartDate>2026-07-01Z</t:StartDate><t:EndDate>2026-08-01Z</t:EndDate></t:EndDateRecurrence>\
|
||||
</t:Recurrence></t:CalendarItem>"
|
||||
);
|
||||
let parsed = parse_calendar_item(&body).unwrap();
|
||||
let rule = crate::exchange_ews::recurrence::to_jscalendar_rule(
|
||||
parsed.recurrence.as_ref().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rule["frequency"], "daily");
|
||||
assert_eq!(rule["interval"], 2);
|
||||
assert_eq!(
|
||||
rule["until"], "2026-08-01T23:59:59",
|
||||
"trailing Z on EndDate must be stripped, not embedded mid-string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_and_relative_recurrence_wire_shapes_round_trip() {
|
||||
let cases = [
|
||||
(
|
||||
"<t:AbsoluteMonthlyRecurrence><t:Interval>1</t:Interval><t:DayOfMonth>15</t:DayOfMonth></t:AbsoluteMonthlyRecurrence><t:NoEndRecurrence><t:StartDate>2026-07-01Z</t:StartDate></t:NoEndRecurrence>",
|
||||
"monthly",
|
||||
),
|
||||
(
|
||||
"<t:RelativeMonthlyRecurrence><t:Interval>1</t:Interval><t:DaysOfWeek>Friday</t:DaysOfWeek><t:DayOfWeekIndex>Last</t:DayOfWeekIndex></t:RelativeMonthlyRecurrence><t:NoEndRecurrence><t:StartDate>2026-07-01Z</t:StartDate></t:NoEndRecurrence>",
|
||||
"monthly",
|
||||
),
|
||||
(
|
||||
"<t:AbsoluteYearlyRecurrence><t:DayOfMonth>1</t:DayOfMonth><t:Month>January</t:Month></t:AbsoluteYearlyRecurrence><t:NoEndRecurrence><t:StartDate>2026-07-01Z</t:StartDate></t:NoEndRecurrence>",
|
||||
"yearly",
|
||||
),
|
||||
(
|
||||
"<t:RelativeYearlyRecurrence><t:DaysOfWeek>Monday</t:DaysOfWeek><t:DayOfWeekIndex>First</t:DayOfWeekIndex><t:Month>September</t:Month></t:RelativeYearlyRecurrence><t:NoEndRecurrence><t:StartDate>2026-07-01Z</t:StartDate></t:NoEndRecurrence>",
|
||||
"yearly",
|
||||
),
|
||||
];
|
||||
for (rec_xml, freq) in cases {
|
||||
let body = format!(
|
||||
"<t:CalendarItem{NS}><t:ItemId Id=\"M\" ChangeKey=\"K\"/><t:UID>u</t:UID>\
|
||||
<t:Start>2026-07-01T15:00:00Z</t:Start><t:End>2026-07-01T15:30:00Z</t:End>\
|
||||
<t:Recurrence>{rec_xml}</t:Recurrence></t:CalendarItem>"
|
||||
);
|
||||
let parsed = parse_calendar_item(&body).unwrap();
|
||||
let rule = crate::exchange_ews::recurrence::to_jscalendar_rule(
|
||||
parsed.recurrence.as_ref().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rule["frequency"], freq, "for {rec_xml}");
|
||||
}
|
||||
let body = format!(
|
||||
"<t:CalendarItem{NS}><t:ItemId Id=\"M\" ChangeKey=\"K\"/><t:UID>u</t:UID>\
|
||||
<t:Start>2026-07-01T15:00:00Z</t:Start><t:End>2026-07-01T15:30:00Z</t:End>\
|
||||
<t:Recurrence><t:RelativeMonthlyRecurrence><t:Interval>1</t:Interval>\
|
||||
<t:DaysOfWeek>Friday</t:DaysOfWeek><t:DayOfWeekIndex>Last</t:DayOfWeekIndex></t:RelativeMonthlyRecurrence>\
|
||||
<t:NoEndRecurrence><t:StartDate>2026-07-01Z</t:StartDate></t:NoEndRecurrence></t:Recurrence></t:CalendarItem>"
|
||||
);
|
||||
let parsed = parse_calendar_item(&body).unwrap();
|
||||
let rule = crate::exchange_ews::recurrence::to_jscalendar_rule(
|
||||
parsed.recurrence.as_ref().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rule["byDay"][0]["day"], "fr");
|
||||
assert_eq!(rule["byDay"][0]["nthOfPeriod"], -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attendee_and_organizer_routing_type_is_parsed() {
|
||||
let body = format!(
|
||||
"<t:CalendarItem{NS}><t:ItemId Id=\"M\" ChangeKey=\"K\"/><t:UID>u</t:UID>\
|
||||
<t:Organizer><t:Mailbox><t:Name>Org</t:Name>\
|
||||
<t:EmailAddress>/o=ExLabs/cn=org</t:EmailAddress><t:RoutingType>EX</t:RoutingType></t:Mailbox></t:Organizer>\
|
||||
<t:RequiredAttendees><t:Attendee><t:Mailbox><t:Name>Att</t:Name>\
|
||||
<t:EmailAddress>/o=ExLabs/cn=att</t:EmailAddress><t:RoutingType>EX</t:RoutingType></t:Mailbox>\
|
||||
<t:ResponseType>Accept</t:ResponseType></t:Attendee></t:RequiredAttendees>\
|
||||
</t:CalendarItem>"
|
||||
);
|
||||
let parsed = parse_calendar_item(&body).unwrap();
|
||||
assert_eq!(parsed.organizer_routing_type.as_deref(), Some("EX"));
|
||||
assert_eq!(parsed.required_attendees.len(), 1);
|
||||
assert_eq!(
|
||||
parsed.required_attendees[0].routing_type.as_deref(),
|
||||
Some("EX")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.required_attendees[0].email.as_deref(),
|
||||
Some("/o=ExLabs/cn=att")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_parses_reminder_resources_and_online_meeting() {
|
||||
let body = format!(
|
||||
|
||||
@@ -372,6 +372,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_version_from_build_exchange_2010_family() {
|
||||
assert_eq!(
|
||||
ServerVersion::from_build(14, 0),
|
||||
ServerVersion::Exchange2010
|
||||
);
|
||||
assert_eq!(
|
||||
ServerVersion::from_build(14, 1),
|
||||
ServerVersion::Exchange2010Sp1
|
||||
);
|
||||
assert_eq!(
|
||||
ServerVersion::from_build(14, 2),
|
||||
ServerVersion::Exchange2010Sp2,
|
||||
"AWS WorkMail reports MajorVersion=14 MinorVersion=2 (Exchange2010_SP2)"
|
||||
);
|
||||
assert_eq!(
|
||||
ServerVersion::from_build(14, 3),
|
||||
ServerVersion::Exchange2010Sp2
|
||||
);
|
||||
assert_eq!(ServerVersion::from_build(8, 3), ServerVersion::Exchange2007);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_version_ladder_descends_to_floor() {
|
||||
let mut v = ServerVersion::Exchange2019;
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::db::exchange_ews_ids;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_ews::calendar_map::to_jscalendar;
|
||||
use crate::exchange_ews::calendar_map::to_jscalendar_with_exceptions;
|
||||
use crate::exchange_ews::parse::{CalendarItemRaw, parse_calendar_item};
|
||||
use crate::exchange_ews::types::{CalendarItemType, ItemId};
|
||||
use crate::exchange_ews::xml::ItemShape;
|
||||
@@ -19,7 +19,8 @@ use crate::sync::TypeCounts;
|
||||
use super::attachments::{fetch_attachments, intern_attachment};
|
||||
use super::folders::FolderPlan;
|
||||
use super::items::{
|
||||
EnumerationMode, ItemRunCtx, delete_vanished, enumerate_folder, for_each_fetched_item, plan_for,
|
||||
EnumerationMode, ItemRunCtx, delete_vanished, enumerate_folder, for_each_fetched_item,
|
||||
get_items, plan_for,
|
||||
};
|
||||
|
||||
pub fn reconcile_all(
|
||||
@@ -176,7 +177,8 @@ fn apply_event(
|
||||
existing_local_id: Option<i64>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let mut built = to_jscalendar(raw);
|
||||
let modified_full = fetch_modified_occurrences(ctx, raw);
|
||||
let mut built = to_jscalendar_with_exceptions(raw, &modified_full);
|
||||
attach_calendar_links(conn, ctx, raw, &mut built.data)?;
|
||||
let calendar_ids = serde_json::json!([local_folder_id]).to_string();
|
||||
let data = built.data.to_string();
|
||||
@@ -229,6 +231,33 @@ fn apply_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_modified_occurrences(ctx: &ItemRunCtx<'_>, raw: &CalendarItemRaw) -> Vec<CalendarItemRaw> {
|
||||
let ids: Vec<ItemId> = raw
|
||||
.modified_occurrences
|
||||
.iter()
|
||||
.filter(|o| !o.item_id.id.is_empty())
|
||||
.map(|o| o.item_id.clone())
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
match get_items(ctx, ItemShape::CalendarItem, &ids) {
|
||||
Ok(outcome) => outcome
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.success)
|
||||
.filter_map(|m| parse_calendar_item(&m.inner_xml).ok())
|
||||
.filter(|occ| !occ.id.id.is_empty())
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
ctx.logger.warn(&format!(
|
||||
"GetItem (calendar exceptions) failed: {e}; overrides keep time-only changes"
|
||||
));
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_calendar_links(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
|
||||
@@ -269,3 +269,39 @@ fn keyword_array(item: &MessageItem) -> String {
|
||||
let value: Value = Value::Array(kws.into_iter().map(Value::String).collect());
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::keyword_array;
|
||||
use crate::exchange_ews::parse::MessageItem;
|
||||
|
||||
#[test]
|
||||
fn read_draft_flagged_and_categories_map_to_jmap_keywords() {
|
||||
let item = MessageItem {
|
||||
is_read: Some(true),
|
||||
is_draft: Some(true),
|
||||
flag_status: Some("Flagged".to_owned()),
|
||||
categories: vec!["Red Category".to_owned(), "VIP".to_owned()],
|
||||
..MessageItem::default()
|
||||
};
|
||||
let kws = keyword_array(&item);
|
||||
for expected in ["$seen", "$draft", "$flagged", "red category", "vip"] {
|
||||
assert!(kws.contains(expected), "missing {expected} in {kws}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_unflagged_message_has_no_seen_or_flagged() {
|
||||
let item = MessageItem {
|
||||
is_read: Some(false),
|
||||
flag_status: Some("NotFlagged".to_owned()),
|
||||
..MessageItem::default()
|
||||
};
|
||||
let kws = keyword_array(&item);
|
||||
assert!(!kws.contains("$seen"), "unread must not be $seen: {kws}");
|
||||
assert!(
|
||||
!kws.contains("$flagged"),
|
||||
"only FlagStatus=Flagged maps to $flagged: {kws}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use vandelay::exchange_ews::parse::{
|
||||
parse_get_attachment_inline, parse_response_messages, parse_sync_folder_items_response,
|
||||
read_envelope_summary,
|
||||
};
|
||||
use vandelay::exchange_ews::types::{FolderId, ItemId, ResponseCode, ServerVersion};
|
||||
use vandelay::exchange_ews::types::{FolderClass, FolderId, ItemId, ResponseCode, ServerVersion};
|
||||
use vandelay::exchange_ews::xml::{
|
||||
FolderRef, ItemShape, Traversal, find_folder_body, find_item_body, get_attachment_body,
|
||||
get_item_body, sync_folder_items_body,
|
||||
@@ -86,6 +86,47 @@ fn find_folder_pagination_and_classification() {
|
||||
assert_eq!(inbox.folder_class, "IPF.Note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_folder_classifies_workmail_folder_class_set() {
|
||||
let body = format!(
|
||||
"<m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:RootFolder TotalItemsInView=\"7\" IncludesLastItemInRange=\"true\"><t:Folders>\
|
||||
<t:Folder><t:FolderId Id=\"INBOX\"/><t:FolderClass>IPF.Note</t:FolderClass><t:DisplayName>Inbox</t:DisplayName></t:Folder>\
|
||||
<t:Folder><t:FolderId Id=\"CUSTOM\"/><t:DisplayName>SyncFolder</t:DisplayName></t:Folder>\
|
||||
<t:Folder><t:FolderId Id=\"RSS\"/><t:FolderClass>IPF.Note.OutlookHomepage</t:FolderClass><t:DisplayName>RSS Feeds</t:DisplayName></t:Folder>\
|
||||
<t:Folder><t:FolderId Id=\"CFG\"/><t:FolderClass>IPF.Configuration</t:FolderClass><t:DisplayName>Quick Step Settings</t:DisplayName></t:Folder>\
|
||||
<t:TasksFolder><t:FolderId Id=\"TASKS\"/><t:FolderClass>IPF.Task</t:FolderClass><t:DisplayName>Tasks</t:DisplayName></t:TasksFolder>\
|
||||
<t:CalendarFolder><t:FolderId Id=\"CAL\"/><t:FolderClass>IPF.Appointment</t:FolderClass><t:DisplayName>Calendar</t:DisplayName></t:CalendarFolder>\
|
||||
<t:ContactsFolder><t:FolderId Id=\"CON\"/><t:FolderClass>IPF.Contact</t:FolderClass><t:DisplayName>Contacts</t:DisplayName></t:ContactsFolder>\
|
||||
</t:Folders></m:RootFolder></m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>"
|
||||
);
|
||||
let parsed = parse_find_folder_response(envelope(&body).as_bytes()).unwrap();
|
||||
let by_id = |id: &str| {
|
||||
parsed
|
||||
.folders
|
||||
.iter()
|
||||
.find(|f| f.folder_id.id == id)
|
||||
.map(|f| FolderClass::from_ipf(&f.folder_class))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(by_id("INBOX"), FolderClass::Mail);
|
||||
assert_eq!(
|
||||
by_id("CUSTOM"),
|
||||
FolderClass::Mail,
|
||||
"a user folder with no FolderClass must import as mail, never be dropped"
|
||||
);
|
||||
assert_eq!(
|
||||
by_id("RSS"),
|
||||
FolderClass::Skipped,
|
||||
"RSS Feeds (IPF.Note.OutlookHomepage) holds feed posts, not mail"
|
||||
);
|
||||
assert_eq!(by_id("CFG"), FolderClass::Skipped);
|
||||
assert_eq!(by_id("TASKS"), FolderClass::Skipped);
|
||||
assert_eq!(by_id("CAL"), FolderClass::Calendar);
|
||||
assert_eq!(by_id("CON"), FolderClass::Contacts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_item_distribution_list_does_not_clobber_preceding_contact() {
|
||||
let body = envelope(&format!(
|
||||
|
||||
Reference in New Issue
Block a user