Skip to content

Commit 73bd2d2

Browse files
committed
feat(calendar): prefer the cancellation email over the generic cancelled note
When a meeting is cancelled, the organizer's cancellation email and the calendar sync's own "This event was cancelled." note can both land on the same event thread. The calendar sync now checks whether a cancellation email for the event has already been recorded and, if so, skips its generic note so the organizer's own message is the one shown — the structural cancellation (status, archived schedule, unread state) is unaffected either way. Adds an optional `readMailState` hook to the Google and Outlook calendar sync host interfaces so the composite Google/Outlook connectors can wire this check through to their mail sync state; standalone calendar-only connectors and existing tests are unaffected since the hook is optional.
1 parent df6bb71 commit 73bd2d2

6 files changed

Lines changed: 185 additions & 2 deletions

File tree

‎connectors/google-calendar/src/sync.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,77 @@ describe("processCalendarEventsFn — past cancellations", () => {
12021202
});
12031203
});
12041204

1205+
describe("processCalendarEventsFn — prefers the cancellation email over the generic note", () => {
1206+
afterEach(() => {
1207+
vi.unstubAllGlobals();
1208+
});
1209+
1210+
const isoDaysFromNow = (n: number) =>
1211+
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
1212+
1213+
it("skips the generic cancellation note when a cancel email is present", async () => {
1214+
const calendarId = "user@example.com";
1215+
const host = makeFakeHost({ calendarId });
1216+
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
1217+
host.readMailState = async <T>(key: string) =>
1218+
(key === "cancel-email:uid-1" ? ({ at: "now" } as T) : null);
1219+
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));
1220+
1221+
const cancelledEvent = {
1222+
id: "e1",
1223+
iCalUID: "uid-1",
1224+
status: "cancelled" as const,
1225+
updated: isoDaysFromNow(-1),
1226+
start: { dateTime: isoDaysFromNow(2) },
1227+
end: { dateTime: isoDaysFromNow(2) },
1228+
summary: "Team sync",
1229+
};
1230+
1231+
await processCalendarEventsFn(host, [cancelledEvent], calendarId, false);
1232+
1233+
const link = host.savedLinks
1234+
.flat()
1235+
.find((l) => l.source === "google-calendar:uid-1");
1236+
expect(link).toBeDefined();
1237+
expect(
1238+
link?.notes?.some((n) => (n as { key?: string }).key === "cancellation")
1239+
).toBe(false);
1240+
// Structural cancellation still applies regardless of the email marker.
1241+
expect(link?.status).toBe("Cancelled");
1242+
expect(link?.schedules?.[0]?.archived).toBe(true);
1243+
});
1244+
1245+
it("keeps the generic cancellation note when no cancel email marker exists", async () => {
1246+
const calendarId = "user@example.com";
1247+
const host = makeFakeHost({ calendarId });
1248+
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
1249+
// No readMailState set on this host at all (mirrors hosts/tests that
1250+
// predate the mail/calendar wiring) — must behave exactly as before.
1251+
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));
1252+
1253+
const cancelledEvent = {
1254+
id: "e2",
1255+
iCalUID: "uid-2",
1256+
status: "cancelled" as const,
1257+
updated: isoDaysFromNow(-1),
1258+
start: { dateTime: isoDaysFromNow(2) },
1259+
end: { dateTime: isoDaysFromNow(2) },
1260+
summary: "Team sync",
1261+
};
1262+
1263+
await processCalendarEventsFn(host, [cancelledEvent], calendarId, false);
1264+
1265+
const link = host.savedLinks
1266+
.flat()
1267+
.find((l) => l.source === "google-calendar:uid-2");
1268+
expect(link).toBeDefined();
1269+
expect(
1270+
link?.notes?.some((n) => (n as { key?: string }).key === "cancellation")
1271+
).toBe(true);
1272+
expect(link?.schedules?.[0]?.archived).toBe(true);
1273+
});
1274+
});
1275+
12051276
describe("processCalendarEventsFn — re-confirmation after cancellation", () => {
12061277
const iso = (n: number) =>
12071278
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();

‎connectors/google-calendar/src/sync.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ export interface CalendarSyncHost {
5353
get<T>(key: string): Promise<T | null>;
5454
/** Delete a persisted value. */
5555
clear(key: string): Promise<void>;
56+
/**
57+
* Optional read into the MAIL namespace's state, used to check for a
58+
* `cancel-email:<uid>` marker recorded when the mail sync processed a
59+
* cancellation email for the same event. Absent on hosts that don't wire
60+
* mail/calendar together (e.g. the standalone GoogleCalendar connector, or
61+
* fake hosts in tests) — treated as "no cancel email seen".
62+
*/
63+
readMailState?<T>(key: string): Promise<T | null>;
5664

5765
tools: {
5866
integrations: {
@@ -758,6 +766,16 @@ export async function processCalendarEventsFn(
758766
}
759767
const cancelMentions = dedupeContactsByEmail(rawCancelMentions);
760768

769+
// Prefer the cancellation email's own message over our generic note
770+
// when the mail sync already recorded one for this event (Plan B
771+
// mail/calendar bundling) — avoids a redundant, lower-fidelity note
772+
// on the same thread. The structural cancellation (status/schedule/
773+
// unread below) always applies regardless of this signal.
774+
const cancelEmailSeen =
775+
(await host.readMailState?.(
776+
`cancel-email:${event.iCalUID ?? event.id}`
777+
)) != null;
778+
761779
const cancelNote = {
762780
key: "cancellation" as const,
763781
content: "This event was cancelled.",
@@ -787,7 +805,7 @@ export async function processCalendarEventsFn(
787805
accessContacts: cancelMentions,
788806
author: authorContact,
789807
meta: activityData.meta ?? null,
790-
notes: [cancelNote],
808+
notes: cancelEmailSeen ? [] : [cancelNote],
791809
schedules: [
792810
{
793811
start: event.start?.dateTime

‎connectors/google/src/google.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@ export class Google extends Connector<Google> {
285285
set: (key, value) => self._calendarHostSet(key, value),
286286
get: <T>(key: string) => self._calendarHostGet<T>(key),
287287
clear: (key) => self._calendarHostClear(key),
288+
// Read into the MAIL namespace so the calendar sync can check for a
289+
// `cancel-email:<uid>` marker recorded by the mail sync (Plan B).
290+
readMailState: (key) => self._mailHostGet(key),
288291
tools: {
289292
integrations: self.tools.integrations as any,
290293
googleContacts: self.tools.googleContacts,

‎connectors/outlook-calendar/src/sync.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,76 @@ describe("processOutlookEventsFn — message-model note/link audiences", () => {
265265
});
266266
});
267267

268+
describe("processOutlookEventsFn — prefers the cancellation email over the generic note", () => {
269+
afterEach(() => {
270+
vi.unstubAllGlobals();
271+
});
272+
273+
const calendarId = "cal-1";
274+
275+
it("skips the generic cancellation note when a cancel email is present", async () => {
276+
const host = makeFakeHost();
277+
host.readMailState = async <T>(key: string) =>
278+
(key === "cancel-email:uid-1" ? ({ at: "now" } as T) : null);
279+
280+
await processOutlookEventsFn(
281+
host,
282+
[
283+
outlookEventFixture({
284+
id: "e1",
285+
iCalUId: "uid-1",
286+
"@removed": { reason: "deleted" },
287+
organizer: { emailAddress: { address: "org@x.com", name: "Org" } },
288+
attendees: [
289+
{ emailAddress: { address: "org@x.com" }, type: "required" },
290+
],
291+
}),
292+
],
293+
calendarId,
294+
false
295+
);
296+
297+
const link = host.savedLinks
298+
.flat()
299+
.find((l) => l.source === `outlook-calendar:${calendarId}:e1`);
300+
expect(link).toBeDefined();
301+
expect(
302+
link?.notes?.some((n) => (n as { key?: string }).key === "cancellation")
303+
).toBe(false);
304+
// Structural cancellation (title/preview) still applies regardless of
305+
// the email marker.
306+
expect(link?.title).toBe("Cancelled Event");
307+
expect(link?.preview).toBe("Cancelled");
308+
});
309+
310+
it("keeps the generic cancellation note when no cancel email marker exists", async () => {
311+
const host = makeFakeHost();
312+
// No readMailState set on this host at all (mirrors hosts/tests that
313+
// predate the mail/calendar wiring) — must behave exactly as before.
314+
315+
await processOutlookEventsFn(
316+
host,
317+
[
318+
outlookEventFixture({
319+
id: "e2",
320+
iCalUId: "uid-2",
321+
"@removed": { reason: "deleted" },
322+
}),
323+
],
324+
calendarId,
325+
false
326+
);
327+
328+
const link = host.savedLinks
329+
.flat()
330+
.find((l) => l.source === `outlook-calendar:${calendarId}:e2`);
331+
expect(link).toBeDefined();
332+
expect(
333+
link?.notes?.some((n) => (n as { key?: string }).key === "cancellation")
334+
).toBe(true);
335+
});
336+
});
337+
268338
// ---------------------------------------------------------------------------
269339
// Event link priority — the calendar event link must always outrank a
270340
// bundled email link (which defaults to priority 0), so the thread keeps

‎connectors/outlook-calendar/src/sync.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ export interface OutlookCalendarSyncHost {
8585
get<T>(key: string): Promise<T | null>;
8686
/** Delete a persisted value. */
8787
clear(key: string): Promise<void>;
88+
/**
89+
* Optional read into the MAIL namespace's state, used to check for a
90+
* `cancel-email:<uid>` marker recorded when the mail sync processed a
91+
* cancellation email for the same event. Absent on hosts that don't wire
92+
* mail/calendar together (e.g. the standalone OutlookCalendar connector, or
93+
* fake hosts in tests) — treated as "no cancel email seen".
94+
*/
95+
readMailState?<T>(key: string): Promise<T | null>;
8896

8997
tools: {
9098
integrations: {
@@ -759,6 +767,16 @@ export async function processOutlookEventsFn(
759767
}
760768
const cancelMentions = dedupeContactsByEmail(rawCancelMentions);
761769

770+
// Prefer the cancellation email's own message over our generic note
771+
// when the mail sync already recorded one for this event (Plan B
772+
// mail/calendar bundling) — avoids a redundant, lower-fidelity note
773+
// on the same thread. The structural cancellation (title/preview/
774+
// unread below) always applies regardless of this signal.
775+
const cancelEmailSeen =
776+
(await host.readMailState?.(
777+
`cancel-email:${outlookEvent.iCalUId ?? outlookEvent.id}`
778+
)) != null;
779+
762780
// Create cancellation note. We don't apply firstSeenAt here
763781
// because cancelled events aren't typically edited further,
764782
// so lastModifiedDateTime is stable.
@@ -795,7 +813,7 @@ export async function processOutlookEventsFn(
795813
// so this is a constant rather than a 100/50 split like Google.
796814
priority: 1,
797815
meta: { syncProvider: "microsoft", syncableId: calendarId },
798-
notes: [cancelNote],
816+
notes: cancelEmailSeen ? [] : [cancelNote],
799817
...(cancelMentions.length > 0
800818
? { access: "private" as const, accessContacts: cancelMentions }
801819
: {}),

‎connectors/outlook/src/outlook.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,9 @@ export class Outlook extends Connector<Outlook> {
617617
set: (key, value) => self._calendarHostSet(key, value),
618618
get: <T>(key: string) => self._calendarHostGet<T>(key),
619619
clear: (key) => self._calendarHostClear(key),
620+
// Read into the MAIL namespace so the calendar sync can check for a
621+
// `cancel-email:<uid>` marker recorded by the mail sync (Plan B).
622+
readMailState: (key) => self._mailHostGet(key),
620623
tools: {
621624
// eslint-disable-next-line @typescript-eslint/no-explicit-any
622625
integrations: self.tools.integrations as any,

0 commit comments

Comments
 (0)