From b93b1bb7b9fabb87ea802d36cd462c096dcef53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Romero?= Date: Wed, 11 Nov 2026 13:05:04 +0100 Subject: [PATCH] fix: match Outlook meetings with their invitations --- crates/flectar-mail-core/src/caldav/task.rs | 14 +- .../src/db/calendar_migrations.rs | 1 + .../db/migrations/calendar_005_series_uid.sql | 6 + .../flectar-mail-core/src/db/repo/calendar.rs | 131 ++++++++++++++++- crates/flectar-mail-core/src/graphcal.rs | 138 +++++++++++++++++- crates/flectar-mail-core/src/lib.rs | 61 +++++++- 6 files changed, 337 insertions(+), 14 deletions(-) create mode 100644 crates/flectar-mail-core/src/db/migrations/calendar_005_series_uid.sql diff --git a/crates/flectar-mail-core/src/caldav/task.rs b/crates/flectar-mail-core/src/caldav/task.rs index 7575df0c..e957aab3 100644 --- a/crates/flectar-mail-core/src/caldav/task.rs +++ b/crates/flectar-mail-core/src/caldav/task.rs @@ -44,6 +44,12 @@ pub struct QueuedAnswer { pub comment: Option, /// Whether the organizer is told, where the server lets that be chosen. pub notify: bool, + /// For an invitation known from mail whose series Exchange holds: the + /// series' Graph id, answered in its stead, and a calendar holding it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meeting: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub calendar_id: Option, } #[derive(Clone)] @@ -482,12 +488,13 @@ impl Cycle<'_> { action: &repo::actions::PendingAction, ) -> Result<()> { let answer: QueuedAnswer = serde_json::from_value(action.payload.clone())?; - let event_id = answer.event_id; + let (event_id, held_in) = (answer.event_id, answer.calendar_id); let (row, calendar) = self .db .read(move |conn| { let row = repo::calendar::sync_row_for(conn, event_id)?; - let calendar = match row.as_ref().and_then(|row| row.event.calendar_id) { + let calendar = match held_in.or(row.as_ref().and_then(|row| row.event.calendar_id)) + { Some(id) => repo::caldav::get_calendar(conn, id)?, None => None, }; @@ -511,7 +518,8 @@ impl Cycle<'_> { let comment = answer.comment.as_deref(); match session { Session::Graph { token } => { - let meeting = row.caldav_href.as_deref().ok_or_else(|| { + let meeting = answer.meeting.as_deref().or(row.caldav_href.as_deref()); + let meeting = meeting.ok_or_else(|| { CoreError::Other("the meeting isn't in the Outlook calendar yet".into()) })?; crate::graph::answer_event(token, meeting, &answer.partstat, comment, answer.notify) diff --git a/crates/flectar-mail-core/src/db/calendar_migrations.rs b/crates/flectar-mail-core/src/db/calendar_migrations.rs index 9e50d98b..ec2792d7 100644 --- a/crates/flectar-mail-core/src/db/calendar_migrations.rs +++ b/crates/flectar-mail-core/src/db/calendar_migrations.rs @@ -144,6 +144,7 @@ CREATE INDEX idx_calendar_actions_due "migrations/calendar_003_invitation_dtstamp.sql" )), Migration::Plain(include_str!("migrations/calendar_004_scheduling.sql")), + Migration::Plain(include_str!("migrations/calendar_005_series_uid.sql")), ]; pub const LATEST_VERSION: i64 = MIGRATIONS.len() as i64; diff --git a/crates/flectar-mail-core/src/db/migrations/calendar_005_series_uid.sql b/crates/flectar-mail-core/src/db/migrations/calendar_005_series_uid.sql new file mode 100644 index 00000000..5e692774 --- /dev/null +++ b/crates/flectar-mail-core/src/db/migrations/calendar_005_series_uid.sql @@ -0,0 +1,6 @@ +-- The series an occurrence synced from Exchange belongs to, by the UID its +-- invitation carried, so that a recurring meeting known from mail meets its +-- occurrences there (MS-OXCICAL keeps that UID inside Exchange's own id). +ALTER TABLE calendar_events ADD COLUMN series_uid TEXT; +CREATE INDEX idx_events_series_uid ON calendar_events(account_id, series_uid) + WHERE series_uid IS NOT NULL; diff --git a/crates/flectar-mail-core/src/db/repo/calendar.rs b/crates/flectar-mail-core/src/db/repo/calendar.rs index 9cf2ee02..45d6c495 100644 --- a/crates/flectar-mail-core/src/db/repo/calendar.rs +++ b/crates/flectar-mail-core/src/db/repo/calendar.rs @@ -176,7 +176,10 @@ pub fn apply_invitation( } Some("COUNTER" | "REFRESH" | "DECLINECOUNTER") => {} Some("CANCEL") => apply_cancel(conn, &invitation)?, - _ => apply_request(conn, &invitation, own)?, + _ => { + apply_request(conn, &invitation, own)?; + join_held_series(conn, account_id, uid)?; + } } } Ok(()) @@ -861,20 +864,64 @@ pub fn delete_by_href(conn: &Connection, calendar_id: i64, href: &str) -> Result /// Record which (unstored) series master an occurrence/exception row belongs /// to, so a later series-wide deletion can find it via `delete_by_href`. +/// Record that the occurrence `href` belongs to Exchange's series +/// `series_master_id`, known by `series_uid`, the UID its invitation +/// carried. An invitation to that series known from mail is then the +/// series Exchange holds: it is answered there and shown from there. pub fn set_series_master( conn: &Connection, calendar_id: i64, href: &str, series_master_id: &str, + series_uid: &str, ) -> Result<()> { conn.execute( - "UPDATE calendar_events SET series_master_id = ?3 + "UPDATE calendar_events SET series_master_id = ?3, series_uid = ?4 WHERE calendar_id = ?1 AND caldav_href = ?2", - params![calendar_id, href, series_master_id], + params![calendar_id, href, series_master_id, series_uid], + )?; + conn.execute( + "UPDATE calendar_events SET series_master_id = ?3 + WHERE calendar_id IS NULL AND message_id IS NOT NULL AND ical_uid = ?2 + AND account_id = (SELECT account_id FROM calendars WHERE id = ?1)", + params![calendar_id, series_uid, series_master_id], )?; Ok(()) } +/// Tie an invitation known from mail to the series Exchange already holds +/// for it, if any (see [`set_series_master`]). +fn join_held_series(conn: &Connection, account_id: i64, uid: &str) -> Result<()> { + conn.execute( + "UPDATE calendar_events SET series_master_id = ( + SELECT held.series_master_id FROM calendar_events held + WHERE held.account_id = ?1 AND held.series_uid = ?2 + AND held.calendar_id IS NOT NULL AND held.series_master_id IS NOT NULL + LIMIT 1) + WHERE account_id = ?1 AND ical_uid = ?2 AND calendar_id IS NULL + AND message_id IS NOT NULL AND series_master_id IS NULL", + params![account_id, uid], + )?; + Ok(()) +} + +/// For an invitation known from mail whose series Exchange holds: that +/// series' Graph id, and a calendar holding its occurrences. +pub fn held_series(conn: &Connection, event_id: i64) -> Result> { + Ok(conn + .query_row( + "SELECT mail.series_master_id, held.calendar_id + FROM calendar_events mail + JOIN calendar_events held ON held.series_master_id = mail.series_master_id + AND held.account_id = mail.account_id AND held.calendar_id IS NOT NULL + WHERE mail.id = ?1 AND mail.calendar_id IS NULL + LIMIT 1", + params![event_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?) +} + /// Record our RSVP (ACCEPTED | TENTATIVE | DECLINED). pub fn set_rsvp(conn: &Connection, event_id: i64, status: &str) -> Result<()> { conn.execute( @@ -927,6 +974,9 @@ pub fn list_range(conn: &Connection, start_ms: i64, end_ms: i64) -> Result Result> AND (calendar_id IS NULL OR EXISTS ( SELECT 1 FROM calendars c WHERE c.id = calendar_events.calendar_id AND c.enabled = 1 )) + -- An invitation known from mail whose series Exchange holds shows + -- from there. + AND (calendar_id IS NOT NULL OR message_id IS NULL OR series_master_id IS NULL) ORDER BY starts_at ASC, id ASC" ))?; let rows = stmt @@ -977,6 +1030,9 @@ pub fn upcoming_for_notify( AND (calendar_id IS NULL OR EXISTS ( SELECT 1 FROM calendars c WHERE c.id = calendar_events.calendar_id AND c.enabled = 1 )) + -- An invitation known from mail whose series Exchange holds shows + -- from there. + AND (calendar_id IS NOT NULL OR message_id IS NULL OR series_master_id IS NULL) AND starts_at > ?1 AND starts_at <= ?1 + ?2 AND (notified_at IS NULL OR notified_at < starts_at) ORDER BY starts_at LIMIT 20" @@ -1284,6 +1340,71 @@ mod tests { assert_eq!(ical_raw(&c, id).unwrap(), raw); } + /// Exchange's occurrences of the SERIES meeting, as Graph sync stores + /// them: each by its own Graph id, tied to the series' Graph id. + fn exchange_occurrences(c: &Connection, calendar: i64) { + for (href, start) in [ + ("occ-1", at("20261102T090000Z")), + ("occ-2", at("20261109T090000Z")), + ] { + let ev = IcsEvent { + uid: href.into(), + summary: Some("Weekly".into()), + starts_at_ms: start, + ends_at_ms: Some(start + 3_600_000), + ..Default::default() + }; + upsert_remote(c, 1, calendar, href, "\"e\"", "", &ev).unwrap(); + set_series_master(c, calendar, href, "master-1", "weekly@test").unwrap(); + } + } + + fn shown(c: &Connection) -> Vec { + list_range(c, 0, i64::MAX / 2) + .unwrap() + .iter() + .map(|event| event.id) + .collect() + } + + #[test] + fn a_recurring_meeting_exchange_holds_shows_from_there_alone() { + let c = testutil::calendar_conn(); + let calendar = seed_calendar(&c); + exchange_occurrences(&c, calendar); + apply(&c, 80, SERIES); + let mail = for_message(&c, 80).unwrap()[0].id; + assert_eq!(shown(&c).len(), 2, "Exchange's two occurrences"); + assert!(!shown(&c).contains(&mail)); + assert!( + recurring_masters(&c, i64::MAX / 2) + .unwrap() + .iter() + .all(|master| master.event.id != mail), + "nor expanded from mail" + ); + assert_eq!( + held_series(&c, mail).unwrap(), + Some(("master-1".into(), calendar)) + ); + } + + #[test] + fn an_invitation_read_first_joins_its_series_when_exchange_syncs() { + let c = testutil::calendar_conn(); + let calendar = seed_calendar(&c); + apply(&c, 80, SERIES); + let mail = for_message(&c, 80).unwrap()[0].id; + assert!(shown(&c).contains(&mail)); + assert_eq!(held_series(&c, mail).unwrap(), None); + exchange_occurrences(&c, calendar); + assert!(!shown(&c).contains(&mail)); + assert_eq!( + held_series(&c, mail).unwrap(), + Some(("master-1".into(), calendar)) + ); + } + #[test] fn an_invitation_to_one_occurrence_follows_that_occurrence() { let c = testutil::calendar_conn(); @@ -1596,8 +1717,8 @@ mod tests { let mut day2 = sample_ics_event(); day2.uid = "occ-2@graph".into(); upsert_remote(&c, 1, cal, "occ-2", "\"e1\"", "", &day2).unwrap(); - set_series_master(&c, cal, "occ-1", "series-master-1").unwrap(); - set_series_master(&c, cal, "occ-2", "series-master-1").unwrap(); + set_series_master(&c, cal, "occ-1", "series-master-1", "series-1@test").unwrap(); + set_series_master(&c, cal, "occ-2", "series-master-1", "series-1@test").unwrap(); assert_eq!(list_range(&c, 0, i64::MAX / 2).unwrap().len(), 2); diff --git a/crates/flectar-mail-core/src/graphcal.rs b/crates/flectar-mail-core/src/graphcal.rs index 45850b17..3a4d9b0f 100644 --- a/crates/flectar-mail-core/src/graphcal.rs +++ b/crates/flectar-mail-core/src/graphcal.rs @@ -228,7 +228,17 @@ async fn sync_calendar( }); } if let Some(master) = &ev.series_master_id { - repo::calendar::set_series_master(&tx, calendar_id, &ev.id, master)?; + let series = ev + .ical_uid + .as_deref() + .map_or_else(|| master.clone(), series_uid); + repo::calendar::set_series_master( + &tx, + calendar_id, + &ev.id, + master, + &series, + )?; } } tx.commit()?; @@ -273,13 +283,75 @@ async fn sync_calendar( Ok((changed_any, new_events)) } +/// The class id every Exchange meeting's GlobalObjectId starts with +/// (MS-OXOCAL 2.2.1.27). +const GLOBAL_OBJECT_ID_CLASS: [u8; 16] = [ + 0x04, 0x00, 0x00, 0x00, 0x82, 0x00, 0xE0, 0x00, 0x74, 0xC5, 0xB7, 0x10, 0x1A, 0x82, 0xE0, 0x08, +]; + +/// The iCalendar UID a meeting got from outside Exchange, when `ical_uid` +/// is the hex form of the GlobalObjectId Exchange keeps it in. After the +/// class id, a date, a creation time and eight reserved bytes come the +/// data's length and the data, which for such a UID is "vCal-Uid", then +/// 01 00 00 00, then the UID (MS-OXCICAL 2.1.3.1.1.20.26). A meeting made in +/// Exchange has no such data: its hex id is its UID. +fn third_party_uid(ical_uid: &str) -> Option { + if ical_uid.len() < 80 || !ical_uid.len().is_multiple_of(2) { + return None; + } + let bytes = (0..ical_uid.len()) + .step_by(2) + .map(|at| { + ical_uid + .get(at..at + 2) + .and_then(|pair| u8::from_str_radix(pair, 16).ok()) + }) + .collect::>>()?; + if bytes.get(..16)? != GLOBAL_OBJECT_ID_CLASS { + return None; + } + let size = u32::from_le_bytes(bytes.get(36..40)?.try_into().ok()?) as usize; + let data = bytes.get(40..40usize.checked_add(size)?)?; + let uid = data.strip_prefix(b"vCal-Uid\x01\x00\x00\x00")?; + let uid = std::str::from_utf8(uid).ok()?.trim_end_matches('\0'); + (!uid.is_empty()).then(|| uid.to_string()) +} + +/// The UID of the series an occurrence's iCalUId belongs to: the UID its +/// invitation carried, or, for a meeting made in Exchange, Exchange's id for +/// the series, which is the occurrence's with its date left out +/// (PidLidCleanGlobalObjectId, MS-OXOCAL 2.2.1.28). +fn series_uid(ical_uid: &str) -> String { + if let Some(uid) = third_party_uid(ical_uid) { + return uid; + } + let is_object_id = ical_uid.len() >= 80 + && ical_uid.is_ascii() + && ical_uid[..32].eq_ignore_ascii_case(&hex(&GLOBAL_OBJECT_ID_CLASS)); + if !is_object_id { + return ical_uid.to_string(); + } + // Bytes 16 to 19 hold the occurrence's date. + format!("{}00000000{}", &ical_uid[..32], &ical_uid[40..]).to_ascii_uppercase() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02X}")).collect() +} + /// Map a Graph delta event onto the shared calendar row shape. Occurrences /// and exceptions of a series all share one iCalUId, so their local uid is /// the (unique) Graph id instead - only single instances keep the iCalUId, /// which lets the row adopt an earlier mail-invite copy of the same event. +/// A meeting from outside Exchange is known by the UID its invitation +/// carried, which Exchange keeps inside its own id. fn ics_event(ev: &graph::DeltaEvent) -> IcsEvent { let uid = match ev.event_type.as_deref() { - Some("singleInstance") | None => ev.ical_uid.clone().unwrap_or_else(|| ev.id.clone()), + Some("singleInstance") | None => ev + .ical_uid + .clone() + .map(|uid| third_party_uid(&uid).unwrap_or(uid)) + .unwrap_or_else(|| ev.id.clone()), _ => ev.id.clone(), }; IcsEvent { @@ -319,3 +391,65 @@ fn ics_event(ev: &graph::DeltaEvent) -> IcsEvent { ), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A GlobalObjectId as Exchange builds it, in hex, around `data`. + fn global_object_id(data: &[u8]) -> String { + occurrence_object_id([0; 4], data) + } + + /// The same for one occurrence: `date` is its year (two bytes), month + /// and day. + fn occurrence_object_id(date: [u8; 4], data: &[u8]) -> String { + let mut bytes = GLOBAL_OBJECT_ID_CLASS.to_vec(); + bytes.extend(date); + bytes.extend([0x10, 0x8D, 0x2B, 0x33, 0x45, 0x67, 0xDC, 0x01]); // creation time + bytes.extend([0; 8]); + bytes.extend((data.len() as u32).to_le_bytes()); + bytes.extend(data); + bytes.iter().map(|byte| format!("{byte:02X}")).collect() + } + + #[test] + fn a_meeting_from_outside_exchange_keeps_its_own_uid() { + let mut data = b"vCal-Uid\x01\x00\x00\x00".to_vec(); + data.extend(b"review-1@example.com\0"); + let hex = global_object_id(&data); + assert_eq!( + third_party_uid(&hex).as_deref(), + Some("review-1@example.com") + ); + assert_eq!( + third_party_uid(&hex.to_ascii_lowercase()).as_deref(), + Some("review-1@example.com") + ); + + // A meeting made in Exchange, and anything else, keeps its iCalUId. + let native = global_object_id(&[0x3A, 0x9F, 0x01, 0xC4, 0x77, 0x0E, 0x5B, 0x12]); + assert_eq!(third_party_uid(&native), None); + assert_eq!(third_party_uid("review-1@example.com"), None); + assert_eq!(third_party_uid(&hex[..hex.len() - 4]), None, "cut short"); + } + + #[test] + fn an_occurrence_belongs_to_the_series_its_invitation_named() { + let november_16 = [0x07, 0xEA, 0x0B, 0x10]; + let mut data = b"vCal-Uid\x01\x00\x00\x00".to_vec(); + data.extend(b"weekly@example.com\0"); + assert_eq!( + series_uid(&occurrence_object_id(november_16, &data)), + "weekly@example.com" + ); + + // A series made in Exchange is its occurrences' id without the date. + let native = [0x3A, 0x9F, 0x01, 0xC4, 0x77, 0x0E, 0x5B, 0x12]; + assert_eq!( + series_uid(&occurrence_object_id(november_16, &native).to_ascii_lowercase()), + global_object_id(&native) + ); + assert_eq!(series_uid("weekly@example.com"), "weekly@example.com"); + } +} diff --git a/crates/flectar-mail-core/src/lib.rs b/crates/flectar-mail-core/src/lib.rs index c702c01c..754a386a 100644 --- a/crates/flectar-mail-core/src/lib.rs +++ b/crates/flectar-mail-core/src/lib.rs @@ -6508,13 +6508,19 @@ impl Core { _ => return Err(CoreError::Other("invalid RSVP response".into())), }; let event_id = args.event_id; - let (row, held_by, scheduling, default) = self + let (row, held_by, held_series, scheduling, default) = self .calendar_db .read(move |conn| { let row = repo::calendar::sync_row_for(conn, event_id)? .ok_or_else(|| CoreError::NotFound("event".into()))?; - // The calendar holding the server's copy, when there is one. - let held_in = row.event.calendar_id.filter(|_| row.caldav_href.is_some()); + // The calendar holding the server's copy, when there is one, + // or the series Exchange holds for an invitation from mail. + let held_series = repo::calendar::held_series(conn, event_id)?; + let held_in = row + .event + .calendar_id + .filter(|_| row.caldav_href.is_some()) + .or(held_series.as_ref().map(|(_, calendar_id)| *calendar_id)); let held_by = match held_in { Some(id) => repo::caldav::get_calendar(conn, id)?, None => None, @@ -6525,7 +6531,7 @@ impl Core { None => None, }; let default = repo::caldav::default_calendar(conn, row.event.account_id)?; - Ok((row, held_by, scheduling, default)) + Ok((row, held_by, held_series, scheduling, default)) }) .await?; let (uid, sequence, invitation) = (row.ical_uid, row.sequence, row.ical_raw); @@ -6618,6 +6624,8 @@ impl Core { attendee: answering.attendee.email.clone(), comment: args.comment.clone(), notify: args.notify, + meeting: held_series.as_ref().map(|(meeting, _)| meeting.clone()), + calendar_id: held_series.as_ref().map(|(_, calendar_id)| *calendar_id), }; let payload = serde_json::to_value(&answer)?; self.calendar_db @@ -13661,6 +13669,51 @@ mod invitation_tests { assert_eq!(states, ["cancelled", "pending"]); } + #[tokio::test] + async fn an_invitation_whose_series_exchange_holds_is_answered_there() { + let (core, _temp, id) = invited().await; + let event = invitations(&core, id).await[0].id; + core.calendar_db + .write(|conn| { + conn.execute( + "INSERT INTO calendar_connections (id, account_id, kind, base_url) + VALUES (5, 1, 'microsoft', 'https://graph.microsoft.com/')", + [], + )?; + conn.execute( + "INSERT INTO calendars (id, account_id, connection_id, url, enabled) + VALUES (50, 1, 5, 'outlook-calendar', 1)", + [], + )?; + let occurrence = calendar::IcsEvent { + uid: "occ-1".into(), + summary: Some("Design review".into()), + starts_at_ms: 1_000, + ends_at_ms: Some(2_000), + ..Default::default() + }; + repo::calendar::upsert_remote(conn, 1, 50, "occ-1", "", "", &occurrence)?; + repo::calendar::set_series_master( + conn, + 50, + "occ-1", + "master-1", + "review-1@example.com", + ) + }) + .await + .unwrap(); + + core.rsvp_event(answer(event, "accepted", None)) + .await + .unwrap(); + assert_eq!(drafts(&core).await, 0, "Exchange tells the organizer"); + let queued = queued_answers(&core).await; + assert_eq!(queued.len(), 1); + assert_eq!(queued[0].1["meeting"], "master-1"); + assert_eq!(queued[0].1["calendarId"], 50); + } + #[tokio::test] async fn a_caldav_server_that_does_not_schedule_keeps_the_answer_and_mail_tells() { let (core, _temp, id) = invited().await;