diff --git a/src/exchange_ews/calendar_map.rs b/src/exchange_ews/calendar_map.rs index 1de4349..ab610e7 100644 --- a/src/exchange_ews/calendar_map.rs +++ b/src/exchange_ews/calendar_map.rs @@ -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 { 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)> { + 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, + raw: &CalendarItemRaw, + modified_full: &[CalendarItemRaw], iana: Option<&str>, ) -> Option { - 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, + base_event: &Map, + recurrence_id: &str, +) -> Map { + 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 { + 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("bob@example.com".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:bob@example.com"); + assert_eq!(att["email"], "bob@example.com"); + } + + #[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()), }], diff --git a/src/exchange_ews/parse.rs b/src/exchange_ews/parse.rs index 96ac129..358c847 100644 --- a/src/exchange_ews/parse.rs +++ b/src/exchange_ews/parse.rs @@ -1089,6 +1089,7 @@ pub struct CalendarItemRaw { pub modified_occurrences: Vec, pub deleted_occurrences: Vec, pub organizer_smtp: Option, + pub organizer_routing_type: Option, pub organizer_name: Option, pub required_attendees: Vec, pub optional_attendees: Vec, @@ -1118,6 +1119,7 @@ pub struct RawOccurrence { #[derive(Debug, Clone, Default)] pub struct RawAttendee { pub email: Option, + pub routing_type: Option, pub name: Option, pub response_type: Option, } @@ -1370,6 +1372,12 @@ pub fn parse_calendar_item(inner_xml: &str) -> Result } 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 "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 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!( + "\ + RecDailyEndu\ + 2026-07-01T15:00:00Z2026-07-01T15:30:00Z\ + RecurringMaster\ + \ + 2\ + 2026-07-01Z2026-08-01Z\ + " + ); + 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 = [ + ( + "1152026-07-01Z", + "monthly", + ), + ( + "1FridayLast2026-07-01Z", + "monthly", + ), + ( + "1January2026-07-01Z", + "yearly", + ), + ( + "MondayFirstSeptember2026-07-01Z", + "yearly", + ), + ]; + for (rec_xml, freq) in cases { + let body = format!( + "u\ + 2026-07-01T15:00:00Z2026-07-01T15:30:00Z\ + {rec_xml}" + ); + 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!( + "u\ + 2026-07-01T15:00:00Z2026-07-01T15:30:00Z\ + 1\ + FridayLast\ + 2026-07-01Z" + ); + 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!( + "u\ + Org\ + /o=ExLabs/cn=orgEX\ + Att\ + /o=ExLabs/cn=attEX\ + Accept\ + " + ); + 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!( diff --git a/src/exchange_ews/types.rs b/src/exchange_ews/types.rs index 679eeb8..d9dfec5 100644 --- a/src/exchange_ews/types.rs +++ b/src/exchange_ews/types.rs @@ -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; diff --git a/src/sync/import_exchange_ews/calendar.rs b/src/sync/import_exchange_ews/calendar.rs index 46ea18c..bfc7b40 100644 --- a/src/sync/import_exchange_ews/calendar.rs +++ b/src/sync/import_exchange_ews/calendar.rs @@ -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, 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 { + let ids: Vec = 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<'_>, diff --git a/src/sync/import_exchange_ews/messages.rs b/src/sync/import_exchange_ews/messages.rs index 1c77ba4..ebbe7ca 100644 --- a/src/sync/import_exchange_ews/messages.rs +++ b/src/sync/import_exchange_ews/messages.rs @@ -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}" + ); + } +} diff --git a/tests/mock_exchange_ews.rs b/tests/mock_exchange_ews.rs index 580bb69..23e72ff 100644 --- a/tests/mock_exchange_ews.rs +++ b/tests/mock_exchange_ews.rs @@ -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!( + "\ + NoError\ + \ + IPF.NoteInbox\ + SyncFolder\ + IPF.Note.OutlookHomepageRSS Feeds\ + IPF.ConfigurationQuick Step Settings\ + IPF.TaskTasks\ + IPF.AppointmentCalendar\ + IPF.ContactContacts\ + " + ); + 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!(