fix: match Outlook meetings with their invitations

This commit is contained in:
Tomás Romero committed 2026-11-11 13:05:04 +01:00
1 parent bf4faa76e2
commit b93b1bb7b9
6 files changed
+337 -14

No files matched your search

+11 -3
View File
@@ -44,6 +44,12 @@ pub struct QueuedAnswer {
pub comment: Option<String>,
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub calendar_id: Option<i64>,
}
#[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)
@@ -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;
@@ -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;
@@ -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<Option<(String, i64)>> {
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<Vec<C
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
@@ -943,6 +993,9 @@ pub fn recurring_masters(conn: &Connection, end_ms: i64) -> Result<Vec<SyncRow>>
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<i64> {
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);
+136 -2
View File
@@ -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<String> {
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::<Option<Vec<u8>>>()?;
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");
}
}
+57 -4
View File
@@ -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;