Skip to content

Commit 4ad8ff5

Browse files
committed
feat(gmail): classify a conversation's calendar-event relationship for bundling
Adds classifyCalendarThread, a pure helper that scans a Gmail thread's messages and decides whether it relates to a calendar event: a reply chain (our own X-Plot-Event-UID header), an ICS update (METHOD:REQUEST, SEQUENCE>0), or a cancellation (METHOD:CANCEL). Bare invites and RSVPs are intentionally skipped.
1 parent 22da0e3 commit 4ad8ff5

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

‎connectors/gmail/src/gmail-api.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildForwardMessage,
55
buildNewEmailMessage,
66
buildReplyMessage,
7+
classifyCalendarThread,
78
formatFromHeader,
89
stripQuotedReply,
910
transformGmailThread,
@@ -550,3 +551,72 @@ describe("buildForwardMessage", () => {
550551
expect(raw).not.toContain("Bcc:");
551552
});
552553
});
554+
555+
describe("classifyCalendarThread", () => {
556+
const icsUpdate =
557+
"BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:uid-1\r\nSEQUENCE:2\r\nEND:VEVENT\r\nEND:VCALENDAR";
558+
const icsInvite =
559+
"BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:uid-1\r\nSEQUENCE:0\r\nEND:VEVENT\r\nEND:VCALENDAR";
560+
const icsCancel =
561+
"BEGIN:VCALENDAR\r\nMETHOD:CANCEL\r\nBEGIN:VEVENT\r\nUID:uid-1\r\nEND:VEVENT\r\nEND:VCALENDAR";
562+
const icsReply =
563+
"BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nBEGIN:VEVENT\r\nUID:uid-1\r\nEND:VEVENT\r\nEND:VCALENDAR";
564+
565+
/** Minimal well-typed GmailMessage wrapping the given top-level payload. */
566+
function baseMessage(payload: GmailMessagePart): GmailMessage {
567+
return {
568+
id: "m1",
569+
threadId: "t1",
570+
labelIds: ["INBOX"],
571+
snippet: "snippet",
572+
historyId: "1",
573+
internalDate: "1700000000000",
574+
sizeEstimate: 500,
575+
payload,
576+
};
577+
}
578+
579+
const msgWithIcs = (ics: string): GmailMessage =>
580+
baseMessage(
581+
part("multipart/mixed", {
582+
parts: [part("text/calendar", { data: ics })],
583+
})
584+
);
585+
586+
const msgWithHeader = (uid: string): GmailMessage =>
587+
baseMessage(
588+
part("text/plain", {
589+
data: "reply body",
590+
headers: [["X-Plot-Event-UID", uid]],
591+
})
592+
);
593+
594+
it("bundles an update (METHOD:REQUEST SEQUENCE>0)", () => {
595+
expect(classifyCalendarThread([msgWithIcs(icsUpdate)])).toEqual({
596+
uid: "uid-1",
597+
kind: "update",
598+
});
599+
});
600+
601+
it("bundles a cancellation (METHOD:CANCEL)", () => {
602+
expect(classifyCalendarThread([msgWithIcs(icsCancel)])).toEqual({
603+
uid: "uid-1",
604+
kind: "cancel",
605+
});
606+
});
607+
608+
it("bundles a reply chain (X-Plot-Event-UID header)", () => {
609+
expect(classifyCalendarThread([msgWithHeader("uid-9")])).toEqual({
610+
uid: "uid-9",
611+
kind: "reply",
612+
});
613+
});
614+
615+
it("skips a bare invite (SEQUENCE 0)", () => {
616+
expect(classifyCalendarThread([msgWithIcs(icsInvite)])).toBeNull();
617+
});
618+
619+
it("skips an RSVP (METHOD:REPLY)", () => {
620+
expect(classifyCalendarThread([msgWithIcs(icsReply)])).toBeNull();
621+
});
622+
});

‎connectors/gmail/src/gmail-api.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,45 @@ function decodeBase64Url(data: string): string {
672672
return new TextDecoder("utf-8").decode(bytes);
673673
}
674674

675+
/** Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and read a property. */
676+
function icsProp(ics: string, name: string): string | null {
677+
const unfolded = ics.replace(/\r?\n[ \t]/g, "");
678+
const re = new RegExp(`^${name}(?:;[^:\\r\\n]*)?:(.*)$`, "im");
679+
const m = unfolded.match(re);
680+
return m ? m[1].trim() : null;
681+
}
682+
683+
/**
684+
* Classify a Gmail conversation's relationship to a calendar event for bundling.
685+
* Two signals: our own `X-Plot-Event-UID` header (a Plot-sent reply chain), or a
686+
* `text/calendar` part (invitation/update/cancellation/RSVP). Only updates,
687+
* cancellations, and reply chains bundle; bare invites and RSVPs are skipped.
688+
*/
689+
export function classifyCalendarThread(
690+
messages: GmailMessage[]
691+
): { uid: string; kind: "reply" | "update" | "cancel" } | null {
692+
// 1. Reply chain — our header on any message.
693+
for (const m of messages) {
694+
const uid = getHeader(m, "X-Plot-Event-UID");
695+
if (uid) return { uid, kind: "reply" };
696+
}
697+
// 2. Calendar-system ICS.
698+
for (const m of messages) {
699+
const ics = findPartContent(m.payload, "text/calendar");
700+
if (!ics) continue;
701+
const uid = icsProp(ics, "UID");
702+
if (!uid) continue;
703+
const method = (icsProp(ics, "METHOD") ?? "").toUpperCase();
704+
if (method === "CANCEL") return { uid, kind: "cancel" };
705+
if (method === "REQUEST") {
706+
const seq = parseInt(icsProp(ics, "SEQUENCE") ?? "0", 10);
707+
if (seq > 0) return { uid, kind: "update" };
708+
}
709+
// METHOD:REPLY, or REQUEST/SEQUENCE 0 → skip.
710+
}
711+
return null;
712+
}
713+
675714
/**
676715
* Locates the start of an Outlook-style "From: / Sent: / To: / Subject:"
677716
* reply header even when the field labels are not wrapped in `<b>` or

0 commit comments

Comments
 (0)