Skip to content

Commit ef3047e

Browse files
committed
feat(gmail): bundle calendar-related email conversations onto the event thread
Wires classifyCalendarThread into saveTransformedThread: a qualifying conversation (a Plot-sent reply chain, or an ICS update/cancellation) gets an icaluid:<uid> alias appended to the link's sources so it bundles onto the calendar event's Plot thread. Cancellation emails also record a cancel-email:<uid> marker for calendar-side suppression.
1 parent 4ad8ff5 commit ef3047e

2 files changed

Lines changed: 122 additions & 4 deletions

File tree

‎connectors/gmail/src/sync.test.ts‎

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22

3-
import type { CreateLinkDraft, Uuid } from "@plotday/twister";
4-
5-
import { GmailApi, GmailApiError, type GmailHeader, type GmailMessage } from "./gmail-api";
6-
import { type GmailSyncHost, onCreateLinkFn, onNoteCreatedFn } from "./sync";
3+
import type { CreateLinkDraft, NewLinkWithNotes, Uuid } from "@plotday/twister";
4+
5+
import {
6+
GmailApi,
7+
GmailApiError,
8+
type GmailHeader,
9+
type GmailMessage,
10+
type GmailMessagePart,
11+
type GmailThread,
12+
} from "./gmail-api";
13+
import {
14+
type GmailSyncHost,
15+
onCreateLinkFn,
16+
onNoteCreatedFn,
17+
processEmailThreadsFn,
18+
} from "./sync";
719

820
/** Decode the base64url raw message the Gmail send API would receive. */
921
function decodeRawMessage(b64url: string): string {
@@ -379,3 +391,93 @@ describe("onNoteCreatedFn — calendar event thread", () => {
379391
expect(res).toBeUndefined();
380392
});
381393
});
394+
395+
/** Build a GmailMessagePart, encoding `data` as base64url like the real API. */
396+
function part(
397+
mimeType: string,
398+
opts: {
399+
data?: string;
400+
parts?: GmailMessagePart[];
401+
headers?: Array<[string, string]>;
402+
} = {}
403+
): GmailMessagePart {
404+
return {
405+
mimeType,
406+
headers: (opts.headers ?? []).map(([name, value]) => ({ name, value })),
407+
body:
408+
opts.data !== undefined
409+
? { size: opts.data.length, data: b64url(opts.data) }
410+
: undefined,
411+
parts: opts.parts,
412+
};
413+
}
414+
415+
/** A single-message GmailThread carrying a `text/calendar` ICS part. */
416+
function calendarUpdateThread(threadId: string, ics: string): GmailThread {
417+
const message: GmailMessage = {
418+
id: `${threadId}-msg-1`,
419+
threadId,
420+
labelIds: ["INBOX"],
421+
snippet: "Event updated",
422+
historyId: "1",
423+
internalDate: "1700000000000",
424+
sizeEstimate: 500,
425+
payload: part("multipart/mixed", {
426+
headers: [
427+
["From", "calendar-notification@google.com"],
428+
["To", "me@example.com"],
429+
["Subject", "Updated: Weekly sync"],
430+
],
431+
parts: [
432+
part("text/plain", { data: "The event has been updated." }),
433+
part("text/calendar", { data: ics }),
434+
],
435+
}),
436+
};
437+
return { id: threadId, historyId: "1", messages: [message] };
438+
}
439+
440+
function gmailThreadWithIcsUpdate(uid: string): GmailThread {
441+
const ics = `BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:${uid}\r\nSEQUENCE:2\r\nEND:VEVENT\r\nEND:VCALENDAR`;
442+
return calendarUpdateThread("cal-update-thread", ics);
443+
}
444+
445+
function gmailThreadWithIcsCancel(uid: string): GmailThread {
446+
const ics = `BEGIN:VCALENDAR\r\nMETHOD:CANCEL\r\nBEGIN:VEVENT\r\nUID:${uid}\r\nEND:VEVENT\r\nEND:VCALENDAR`;
447+
return calendarUpdateThread("cal-cancel-thread", ics);
448+
}
449+
450+
describe("processEmailThreadsFn — calendar-thread bundling", () => {
451+
it("adds icaluid:<uid> to sources when the conversation is a calendar update", async () => {
452+
const { host } = makeHost();
453+
const saved: NewLinkWithNotes[] = [];
454+
(host.tools.integrations.saveLink as ReturnType<typeof vi.fn>).mockImplementation(
455+
async (l: NewLinkWithNotes) => {
456+
saved.push(l);
457+
return "T";
458+
}
459+
);
460+
461+
await processEmailThreadsFn(
462+
host,
463+
[gmailThreadWithIcsUpdate("uid-1")],
464+
false,
465+
"INBOX"
466+
);
467+
468+
expect(saved[0].sources).toContain("icaluid:uid-1");
469+
});
470+
471+
it("records a cancel-email marker for a cancellation email", async () => {
472+
const { host, store } = makeHost();
473+
474+
await processEmailThreadsFn(
475+
host,
476+
[gmailThreadWithIcsCancel("uid-1")],
477+
false,
478+
"INBOX"
479+
);
480+
481+
expect(store.get("cancel-email:uid-1")).toBeTruthy();
482+
});
483+
});

‎connectors/gmail/src/sync.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
buildForwardMessage,
4747
buildNewEmailMessage,
4848
buildReplyMessage,
49+
classifyCalendarThread,
4950
collectAttachments,
5051
extractBody,
5152
formatFromHeader,
@@ -1439,6 +1440,21 @@ async function saveTransformedThread(
14391440
syncableId: channelId,
14401441
};
14411442

1443+
// Bundle onto the calendar event's thread when this conversation relates
1444+
// to one (a Plot-sent reply chain, or an ICS update/cancellation).
1445+
const calBundle = classifyCalendarThread(thread.messages ?? []);
1446+
if (calBundle) {
1447+
plotThread.sources = [
1448+
...(plotThread.sources ?? []),
1449+
`icaluid:${calBundle.uid}`,
1450+
];
1451+
if (calBundle.kind === "cancel") {
1452+
await host.set(`cancel-email:${calBundle.uid}`, {
1453+
at: new Date().toISOString(),
1454+
});
1455+
}
1456+
}
1457+
14421458
// Compute classifier facets from the parent message's headers + body.
14431459
const facetParent = thread.messages.find(
14441460
(m) => !m.labelIds?.includes("DRAFT")

0 commit comments

Comments
 (0)