Skip to content

Commit 127e6af

Browse files
committed
feat(outlook-mail): bundle calendar-related email conversations onto the event thread
Adds a pure classifyOutlookCalendar(messages, parentHeaders) that identifies whether an Outlook conversation relates to a calendar event: our own X-Plot-Event-UID header on the parent message (a Plot-sent reply chain, checked first), or Graph meeting-message metadata indicating an update or cancellation. Bare new invites and RSVP responses are left unclassified. Wires the classifier into processConversationsFn: a match appends icaluid:<uid> to the conversation's link sources so it bundles onto the event's thread, and cancellations record a cancel-email:<uid> marker.
1 parent 04e1f70 commit 127e6af

4 files changed

Lines changed: 340 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import type { NewLinkWithNotes } from "@plotday/twister/plot";
4+
5+
import type { GraphAttachmentMeta, GraphHeader, GraphMessage } from "./graph-mail-api";
6+
import { type OutlookMailSyncHost, processConversationsFn } from "./sync";
7+
8+
/**
9+
* Wiring test for calendar-thread bundling: `processConversationsFn`
10+
* classifies each conversation via `classifyOutlookCalendar` (pure logic
11+
* covered exhaustively in graph-mail-api.test.ts) and, on a match, appends
12+
* `icaluid:<uid>` to the saved link's `sources` — plus records a
13+
* `cancel-email:<uid>` marker for cancellations. `processConversationsFn`
14+
* takes already-fetched `ConversationItem`s directly (messages +
15+
* attachments + parentHeaders), so no Graph API mocking is needed here —
16+
* only a host stub.
17+
*/
18+
19+
function makeHost(): {
20+
host: OutlookMailSyncHost;
21+
map: Map<string, unknown>;
22+
saved: NewLinkWithNotes[];
23+
} {
24+
const map = new Map<string, unknown>([
25+
["user_email", "me@work.com"],
26+
["wellknown_folders", { inbox: "inbox-folder" }],
27+
]);
28+
const saved: NewLinkWithNotes[] = [];
29+
const host = {
30+
id: "twist-instance-1",
31+
get: vi.fn(async (key: string) => (map.has(key) ? map.get(key) : null)),
32+
set: vi.fn(async (key: string, value: unknown) => {
33+
map.set(key, value);
34+
}),
35+
setMany: vi.fn(async (entries: [string, unknown][]) => {
36+
for (const [key, value] of entries) map.set(key, value);
37+
}),
38+
clear: vi.fn(async (key: string) => {
39+
map.delete(key);
40+
}),
41+
tools: {
42+
integrations: {
43+
// No token → contact enrichment is skipped (best-effort, non-blocking).
44+
get: vi.fn(async () => null),
45+
saveLink: vi.fn(async (link: NewLinkWithNotes) => {
46+
saved.push(link);
47+
return "T";
48+
}),
49+
setThreadToDo: vi.fn(async () => {}),
50+
},
51+
},
52+
} as unknown as OutlookMailSyncHost;
53+
return { host, map, saved };
54+
}
55+
56+
/** A single non-draft, non-meeting message with the fields the transform needs. */
57+
function baseMessage(over: Partial<GraphMessage> = {}): GraphMessage {
58+
return {
59+
id: "msg-1",
60+
conversationId: "conv-1",
61+
internetMessageId: "<msg-1@x>",
62+
subject: "Weekly sync",
63+
bodyPreview: "preview",
64+
body: { contentType: "text", content: "Body" },
65+
from: { emailAddress: { name: "Ann", address: "ann@x.com" } },
66+
toRecipients: [{ emailAddress: { name: "Me", address: "me@work.com" } }],
67+
receivedDateTime: "2026-06-01T10:00:00Z",
68+
isRead: true,
69+
isDraft: false,
70+
parentFolderId: "inbox-folder",
71+
...over,
72+
};
73+
}
74+
75+
const emptyAttachments = new Map<string, GraphAttachmentMeta[]>();
76+
77+
describe("processConversationsFn — calendar-thread bundling", () => {
78+
it("adds icaluid:<uid> to sources when the conversation is a calendar update", async () => {
79+
const { host, saved } = makeHost();
80+
const item = {
81+
messages: [
82+
baseMessage({
83+
meetingMessageType: "meetingRequest",
84+
meetingRequestType: "fullUpdate",
85+
event: { iCalUId: "uid-1" },
86+
}),
87+
],
88+
attachmentsByMessageId: emptyAttachments,
89+
parentHeaders: null as GraphHeader[] | null,
90+
};
91+
92+
await processConversationsFn(host, [item], false, "inbox-folder");
93+
94+
expect(saved).toHaveLength(1);
95+
expect(saved[0].sources).toContain("icaluid:uid-1");
96+
});
97+
98+
it("records a cancel-email marker for a cancellation email", async () => {
99+
const { host, map } = makeHost();
100+
const item = {
101+
messages: [
102+
baseMessage({
103+
id: "msg-2",
104+
internetMessageId: "<msg-2@x>",
105+
conversationId: "conv-2",
106+
meetingMessageType: "meetingCancelled",
107+
event: { iCalUId: "uid-2" },
108+
}),
109+
],
110+
attachmentsByMessageId: emptyAttachments,
111+
parentHeaders: null as GraphHeader[] | null,
112+
};
113+
114+
await processConversationsFn(host, [item], false, "inbox-folder");
115+
116+
expect(map.get("cancel-email:uid-2")).toBeTruthy();
117+
});
118+
119+
it("bundles a Plot-sent reply chain via the X-Plot-Event-UID parent header", async () => {
120+
const { host, saved } = makeHost();
121+
const item = {
122+
messages: [
123+
baseMessage({
124+
id: "msg-3",
125+
internetMessageId: "<msg-3@x>",
126+
conversationId: "conv-3",
127+
}),
128+
],
129+
attachmentsByMessageId: emptyAttachments,
130+
parentHeaders: [
131+
{ name: "X-Plot-Event-UID", value: "uid-3" },
132+
] as GraphHeader[] | null,
133+
};
134+
135+
await processConversationsFn(host, [item], false, "inbox-folder");
136+
137+
expect(saved).toHaveLength(1);
138+
expect(saved[0].sources).toContain("icaluid:uid-3");
139+
});
140+
141+
it("does not add icaluid sources for a plain conversation with no calendar signal", async () => {
142+
const { host, saved } = makeHost();
143+
const item = {
144+
messages: [
145+
baseMessage({
146+
id: "msg-4",
147+
internetMessageId: "<msg-4@x>",
148+
conversationId: "conv-4",
149+
}),
150+
],
151+
attachmentsByMessageId: emptyAttachments,
152+
parentHeaders: null as GraphHeader[] | null,
153+
};
154+
155+
await processConversationsFn(host, [item], false, "inbox-folder");
156+
157+
expect(saved).toHaveLength(1);
158+
expect(saved[0].sources ?? []).not.toEqual(
159+
expect.arrayContaining([expect.stringMatching(/^icaluid:/)])
160+
);
161+
});
162+
});

‎connectors/outlook-mail/src/graph-mail-api.test.ts‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
classifyOutlookCalendar,
34
conversationSource,
45
GraphMailApi,
56
isConversationFlagged,
@@ -10,6 +11,7 @@ import {
1011
sortConversation,
1112
transformOutlookConversation,
1213
type GraphAttachmentMeta,
14+
type GraphHeader,
1315
type GraphMessage,
1416
} from "./graph-mail-api";
1517

@@ -213,3 +215,129 @@ describe("GraphMailApi queries", () => {
213215
expect(calls[0]?.$expand).toBe("event($select=iCalUId)");
214216
});
215217
});
218+
219+
describe("classifyOutlookCalendar", () => {
220+
const header = (name: string, value: string): GraphHeader => ({ name, value });
221+
222+
it("reply chain via X-Plot-Event-UID header (checked before any message signal)", () => {
223+
expect(
224+
classifyOutlookCalendar([], [header("x-plot-event-uid", "uid-1")])
225+
).toEqual({ uid: "uid-1", kind: "reply" });
226+
});
227+
228+
it("header match is case-insensitive", () => {
229+
expect(
230+
classifyOutlookCalendar([], [header("X-PLOT-EVENT-UID", "uid-3")])
231+
).toEqual({ uid: "uid-3", kind: "reply" });
232+
});
233+
234+
it("header takes priority over a message-derived cancel/update signal", () => {
235+
expect(
236+
classifyOutlookCalendar(
237+
[
238+
msg({
239+
meetingMessageType: "meetingCancelled",
240+
event: { iCalUId: "uid-msg" },
241+
}),
242+
],
243+
[header("X-Plot-Event-UID", "uid-header")]
244+
)
245+
).toEqual({ uid: "uid-header", kind: "reply" });
246+
});
247+
248+
it("update: meetingRequest fullUpdate", () => {
249+
expect(
250+
classifyOutlookCalendar(
251+
[
252+
msg({
253+
meetingMessageType: "meetingRequest",
254+
meetingRequestType: "fullUpdate",
255+
event: { iCalUId: "uid-1" },
256+
}),
257+
],
258+
null
259+
)
260+
).toEqual({ uid: "uid-1", kind: "update" });
261+
});
262+
263+
it("update: meetingRequest informationalUpdate", () => {
264+
expect(
265+
classifyOutlookCalendar(
266+
[
267+
msg({
268+
meetingMessageType: "meetingRequest",
269+
meetingRequestType: "informationalUpdate",
270+
event: { iCalUId: "uid-2" },
271+
}),
272+
],
273+
null
274+
)
275+
).toEqual({ uid: "uid-2", kind: "update" });
276+
});
277+
278+
it("cancel: meetingCancelled", () => {
279+
expect(
280+
classifyOutlookCalendar(
281+
[
282+
msg({
283+
meetingMessageType: "meetingCancelled",
284+
event: { iCalUId: "uid-1" },
285+
}),
286+
],
287+
null
288+
)
289+
).toEqual({ uid: "uid-1", kind: "cancel" });
290+
});
291+
292+
it("skips new invite + RSVP", () => {
293+
expect(
294+
classifyOutlookCalendar(
295+
[
296+
msg({
297+
meetingMessageType: "meetingRequest",
298+
meetingRequestType: "newMeetingRequest",
299+
event: { iCalUId: "u" },
300+
}),
301+
],
302+
null
303+
)
304+
).toBeNull();
305+
expect(
306+
classifyOutlookCalendar(
307+
[msg({ meetingMessageType: "meetingAccepted", event: { iCalUId: "u" } })],
308+
null
309+
)
310+
).toBeNull();
311+
});
312+
313+
it("skips a qualifying meetingMessageType without an event.iCalUId", () => {
314+
expect(
315+
classifyOutlookCalendar(
316+
[msg({ meetingMessageType: "meetingCancelled", event: undefined })],
317+
null
318+
)
319+
).toBeNull();
320+
});
321+
322+
it("returns null for plain messages with no calendar signal at all", () => {
323+
expect(classifyOutlookCalendar([msg({})], null)).toBeNull();
324+
expect(classifyOutlookCalendar([], null)).toBeNull();
325+
expect(classifyOutlookCalendar([], [])).toBeNull();
326+
});
327+
328+
it("scans past a non-qualifying message to find a later qualifying one", () => {
329+
expect(
330+
classifyOutlookCalendar(
331+
[
332+
msg({ id: "id-1", meetingMessageType: undefined }),
333+
msg({
334+
id: "id-2",
335+
meetingMessageType: "meetingCancelled",
336+
event: { iCalUId: "uid-later" },
337+
}),
338+
],
339+
null
340+
)
341+
).toEqual({ uid: "uid-later", kind: "cancel" });
342+
});
343+
});

‎connectors/outlook-mail/src/graph-mail-api.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,37 @@ export function sortConversation(messages: GraphMessage[]): GraphMessage[] {
598598
);
599599
}
600600

601+
/**
602+
* Classify an Outlook conversation's relationship to a calendar event for
603+
* bundling onto the event's Plot thread. Two signals: our own
604+
* `X-Plot-Event-UID` header on the parent's raw headers (a Plot-sent reply
605+
* chain — checked first, regardless of any message-derived signal), or a
606+
* message's Graph meeting-message metadata (update/cancellation). Bare new
607+
* invites and RSVP responses (accept/decline/tentative) are skipped.
608+
*/
609+
export function classifyOutlookCalendar(
610+
messages: GraphMessage[],
611+
parentHeaders: GraphHeader[] | null
612+
): { uid: string; kind: "reply" | "update" | "cancel" } | null {
613+
const hdr = (parentHeaders ?? []).find(
614+
(h) => h.name.toLowerCase() === "x-plot-event-uid"
615+
);
616+
if (hdr?.value) return { uid: hdr.value, kind: "reply" };
617+
for (const m of messages) {
618+
const uid = m.event?.iCalUId;
619+
if (!uid) continue;
620+
if (m.meetingMessageType === "meetingCancelled") return { uid, kind: "cancel" };
621+
if (
622+
m.meetingMessageType === "meetingRequest" &&
623+
(m.meetingRequestType === "fullUpdate" ||
624+
m.meetingRequestType === "informationalUpdate")
625+
) {
626+
return { uid, kind: "update" };
627+
}
628+
}
629+
return null;
630+
}
631+
601632
/**
602633
* Transforms an Outlook conversation into a NewLinkWithNotes. The subject
603634
* becomes the link title, and each non-draft message becomes a note keyed

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
EXCLUDED_WELL_KNOWN,
4242
GraphMailApi,
4343
GraphMailApiError,
44+
classifyOutlookCalendar,
4445
conversationSource,
4546
isConversationFlagged,
4647
isConversationUnread,
@@ -1479,6 +1480,24 @@ export async function processConversationsFn(
14791480
channelId,
14801481
};
14811482

1483+
// Bundle onto the calendar event's thread when this conversation relates
1484+
// to one (a Plot-sent reply chain, or a meeting update/cancellation).
1485+
const calBundle = classifyOutlookCalendar(
1486+
item.messages,
1487+
item.parentHeaders
1488+
);
1489+
if (calBundle) {
1490+
plotThread.sources = [
1491+
...(plotThread.sources ?? []),
1492+
`icaluid:${calBundle.uid}`,
1493+
];
1494+
if (calBundle.kind === "cancel") {
1495+
await host.set(`cancel-email:${calBundle.uid}`, {
1496+
at: new Date().toISOString(),
1497+
});
1498+
}
1499+
}
1500+
14821501
// Compute classifier facets from the parent message's headers + body.
14831502
const facetParent = sortConversation(item.messages).find(
14841503
(m) => !m.isDraft

0 commit comments

Comments
 (0)