From f7371cc2aefe29b1ec9b7751c50bc20b787632c7 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:27:39 +0200 Subject: [PATCH] Fix MS Exchange EWS: add support for version negotiation and other enhancements (closes #6) --- src/exchange_ews/recurrence.rs | 171 +++++++++++++++++++------- src/exchange_ews/xml.rs | 47 ++++++- src/sync/import_exchange_ews/items.rs | 3 +- tests/mock_exchange_ews.rs | 2 +- 4 files changed, 170 insertions(+), 53 deletions(-) diff --git a/src/exchange_ews/recurrence.rs b/src/exchange_ews/recurrence.rs index d7ead56..17ea253 100644 --- a/src/exchange_ews/recurrence.rs +++ b/src/exchange_ews/recurrence.rs @@ -27,7 +27,8 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option { } let days: Vec = days_of_week .iter() - .filter_map(|d| day_token(d).map(|t| json!({"day": t}))) + .filter_map(|d| expand_day(d)) + .flat_map(|slice| slice.iter().map(|t| json!({"day": t}))) .collect(); if !days.is_empty() { rule.insert("byDay".to_owned(), Value::Array(days)); @@ -55,23 +56,7 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option { if *interval > 1 { rule.insert("interval".to_owned(), Value::from(*interval)); } - let nth = nth_of_period(day_of_week_index); - let days: Vec = days_of_week - .iter() - .filter_map(|d| { - day_token(d).map(|t| { - let mut o = Map::new(); - o.insert("day".to_owned(), Value::String(t.to_owned())); - if let Some(n) = nth { - o.insert("nthOfPeriod".to_owned(), Value::from(n)); - } - Value::Object(o) - }) - }) - .collect(); - if !days.is_empty() { - rule.insert("byDay".to_owned(), Value::Array(days)); - } + insert_relative_by_day(&mut rule, days_of_week, day_of_week_index); } RecurrencePattern::AbsoluteYearly { month, @@ -101,23 +86,7 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option { Value::Array(vec![Value::String(n.to_string())]), ); } - let nth = nth_of_period(day_of_week_index); - let days: Vec = days_of_week - .iter() - .filter_map(|d| { - day_token(d).map(|t| { - let mut o = Map::new(); - o.insert("day".to_owned(), Value::String(t.to_owned())); - if let Some(n) = nth { - o.insert("nthOfPeriod".to_owned(), Value::from(n)); - } - Value::Object(o) - }) - }) - .collect(); - if !days.is_empty() { - rule.insert("byDay".to_owned(), Value::Array(days)); - } + insert_relative_by_day(&mut rule, days_of_week, day_of_week_index); } } match raw.range.as_ref() { @@ -140,16 +109,60 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option { Some(Value::Object(rule)) } -fn day_token(d: &str) -> Option<&'static str> { - match d.to_ascii_lowercase().as_str() { - "monday" | "mo" => Some("mo"), - "tuesday" | "tu" => Some("tu"), - "wednesday" | "we" => Some("we"), - "thursday" | "th" => Some("th"), - "friday" | "fr" => Some("fr"), - "saturday" | "sa" => Some("sa"), - "sunday" | "su" => Some("su"), - _ => None, +fn expand_day(d: &str) -> Option<&'static [&'static str]> { + Some(match d.to_ascii_lowercase().as_str() { + "monday" | "mo" => &["mo"], + "tuesday" | "tu" => &["tu"], + "wednesday" | "we" => &["we"], + "thursday" | "th" => &["th"], + "friday" | "fr" => &["fr"], + "saturday" | "sa" => &["sa"], + "sunday" | "su" => &["su"], + "day" => &["mo", "tu", "we", "th", "fr", "sa", "su"], + "weekday" => &["mo", "tu", "we", "th", "fr"], + "weekendday" => &["sa", "su"], + _ => return None, + }) +} + +fn insert_relative_by_day(rule: &mut Map, days_of_week: &[String], index: &str) { + let nth = nth_of_period(index); + let mut days: Vec<&'static str> = Vec::new(); + let mut is_set = false; + for d in days_of_week { + if let Some(slice) = expand_day(d) { + if slice.len() > 1 { + is_set = true; + } + for &t in slice { + if !days.contains(&t) { + days.push(t); + } + } + } + } + if days.is_empty() { + return; + } + if is_set { + let by_day: Vec = days.iter().map(|t| json!({"day": t})).collect(); + rule.insert("byDay".to_owned(), Value::Array(by_day)); + if let Some(n) = nth { + rule.insert("bySetPosition".to_owned(), json!([n])); + } + } else { + let by_day: Vec = days + .iter() + .map(|t| { + let mut o = Map::new(); + o.insert("day".to_owned(), Value::String((*t).to_owned())); + if let Some(n) = nth { + o.insert("nthOfPeriod".to_owned(), Value::from(n)); + } + Value::Object(o) + }) + .collect(); + rule.insert("byDay".to_owned(), Value::Array(by_day)); } } @@ -286,6 +299,74 @@ mod tests { assert!(rule.get("count").is_none()); } + #[test] + fn relative_monthly_first_weekday_expands_with_set_position() { + let raw = RawRecurrence { + pattern: Some(RecurrencePattern::RelativeMonthly { + interval: 1, + day_of_week_index: "First".to_owned(), + days_of_week: vec!["Weekday".to_owned()], + }), + range: Some(RecurrenceRange::NoEnd { + start_date: "2025-01-01".to_owned(), + }), + }; + let rule = to_jscalendar_rule(&raw).unwrap(); + let by_day = rule["byDay"].as_array().unwrap(); + let days: Vec<&str> = by_day.iter().map(|d| d["day"].as_str().unwrap()).collect(); + assert_eq!(days, ["mo", "tu", "we", "th", "fr"]); + assert!( + by_day.iter().all(|d| d.get("nthOfPeriod").is_none()), + "set-based byDay must not carry per-day nthOfPeriod" + ); + assert_eq!(rule["bySetPosition"], json!([1])); + } + + #[test] + fn relative_monthly_last_weekendday_uses_negative_set_position() { + let raw = RawRecurrence { + pattern: Some(RecurrencePattern::RelativeMonthly { + interval: 1, + day_of_week_index: "Last".to_owned(), + days_of_week: vec!["WeekendDay".to_owned()], + }), + range: Some(RecurrenceRange::NoEnd { + start_date: "2025-01-01".to_owned(), + }), + }; + let rule = to_jscalendar_rule(&raw).unwrap(); + let days: Vec<&str> = rule["byDay"] + .as_array() + .unwrap() + .iter() + .map(|d| d["day"].as_str().unwrap()) + .collect(); + assert_eq!(days, ["sa", "su"]); + assert_eq!(rule["bySetPosition"], json!([-1])); + } + + #[test] + fn weekly_every_weekday_expands() { + let raw = RawRecurrence { + pattern: Some(RecurrencePattern::Weekly { + interval: 1, + days_of_week: vec!["Weekday".to_owned()], + }), + range: Some(RecurrenceRange::NoEnd { + start_date: "2025-01-06".to_owned(), + }), + }; + let rule = to_jscalendar_rule(&raw).unwrap(); + let days: Vec<&str> = rule["byDay"] + .as_array() + .unwrap() + .iter() + .map(|d| d["day"].as_str().unwrap()) + .collect(); + assert_eq!(days, ["mo", "tu", "we", "th", "fr"]); + assert!(rule.get("bySetPosition").is_none()); + } + #[test] fn weekly_two_days() { let raw = RawRecurrence { diff --git a/src/exchange_ews/xml.rs b/src/exchange_ews/xml.rs index fca2d2c..45adad9 100644 --- a/src/exchange_ews/xml.rs +++ b/src/exchange_ews/xml.rs @@ -99,7 +99,12 @@ pub fn find_item_body( out } -pub fn sync_folder_items_body(folder: &FolderId, sync_state: &str, max_changes: u32) -> String { +pub fn sync_folder_items_body( + folder: &FolderId, + sync_state: &str, + max_changes: u32, + version: ServerVersion, +) -> String { let mut out = String::with_capacity(512); out.push_str(""); out.push_str("IdOnly"); @@ -112,7 +117,9 @@ pub fn sync_folder_items_body(folder: &FolderId, sync_state: &str, max_changes: out.push_str(""); out.push_str(&max_changes.to_string()); out.push_str(""); - out.push_str("NormalItems"); + if version >= ServerVersion::Exchange2010 { + out.push_str("NormalItems"); + } out.push_str(""); out } @@ -153,6 +160,10 @@ pub fn get_item_body(shape: ItemShape, ids: &[ItemId], version: ServerVersion) - out.push_str(f); out.push_str("\"/>"); } + if version >= ServerVersion::Exchange2010 { + out.push_str(""); + out.push_str(""); + } out.push_str(""); } ItemShape::Contact => { @@ -190,8 +201,6 @@ const CALENDAR_FIELDS: &[&str] = &[ "calendar:Recurrence", "calendar:ModifiedOccurrences", "calendar:DeletedOccurrences", - "calendar:StartTimeZone", - "calendar:EndTimeZone", "calendar:Duration", "item:Subject", "item:Body", @@ -349,17 +358,43 @@ mod tests { #[test] fn sync_folder_items_body_carries_state_and_max() { let folder = FolderId::new("FID", "FCK"); - let body = sync_folder_items_body(&folder, "STATE", 512); + let body = sync_folder_items_body(&folder, "STATE", 512, ServerVersion::Exchange2013Sp1); assert!(body.contains("")); assert!(body.contains("STATE")); assert!(body.contains("512")); assert!(body.contains("NormalItems")); } + #[test] + fn sync_scope_omitted_below_exchange_2010() { + let folder = FolderId::new("FID", ""); + let modern = sync_folder_items_body(&folder, "", 100, ServerVersion::Exchange2010); + assert!(modern.contains("NormalItems")); + let legacy = sync_folder_items_body(&folder, "", 100, ServerVersion::Exchange2007); + assert!( + !legacy.contains("SyncScope"), + "SyncScope is an Exchange 2010 addition" + ); + } + + #[test] + fn calendar_timezone_fields_gated_on_exchange_2010() { + let modern = + get_item_body(ItemShape::CalendarItem, &[ItemId::new("X", "")], ServerVersion::Exchange2010); + assert!(modern.contains("calendar:StartTimeZone")); + assert!(modern.contains("calendar:EndTimeZone")); + let legacy = + get_item_body(ItemShape::CalendarItem, &[ItemId::new("X", "")], ServerVersion::Exchange2007); + assert!( + !legacy.contains("TimeZone"), + "StartTimeZone/EndTimeZone are Exchange 2010 additions" + ); + } + #[test] fn sync_folder_items_empty_state_round_trips() { let folder = FolderId::new("FID", ""); - let body = sync_folder_items_body(&folder, "", 100); + let body = sync_folder_items_body(&folder, "", 100, ServerVersion::Exchange2013Sp1); assert!(body.contains("")); } diff --git a/src/sync/import_exchange_ews/items.rs b/src/sync/import_exchange_ews/items.rs index adb177e..2accbf9 100644 --- a/src/sync/import_exchange_ews/items.rs +++ b/src/sync/import_exchange_ews/items.rs @@ -93,9 +93,10 @@ fn try_sync_folder_items( let mut deletions: Vec = Vec::new(); let mut iters = 0; let mut batch = ctx.sync_batch.clamp(SYNC_BATCH_MIN, SYNC_BATCH_MAX); + let version = ctx.client.server_version(); loop { let retries_before = ctx.client.retries_observed(); - let body = sync_folder_items_body(folder, &sync_state, batch); + let body = sync_folder_items_body(folder, &sync_state, batch, version); let resp = match ctx.client.call(ctx.url, "SyncFolderItems", &body) { Ok(r) => r, Err(EwsError::SoapFault { diff --git a/tests/mock_exchange_ews.rs b/tests/mock_exchange_ews.rs index d8fc104..47c9318 100644 --- a/tests/mock_exchange_ews.rs +++ b/tests/mock_exchange_ews.rs @@ -469,7 +469,7 @@ fn get_item_batches_chunk_the_id_list() { #[test] fn sync_folder_items_request_body_carries_state_and_max() { let folder = FolderId::new("FID", "FCK"); - let body = sync_folder_items_body(&folder, "OPAQUE", 512); + let body = sync_folder_items_body(&folder, "OPAQUE", 512, ServerVersion::Exchange2013Sp1); assert!(body.contains("OPAQUE")); assert!(body.contains("512")); assert!(body.contains("NormalItems"));