Skip to content

Commit 24fed62

Browse files
KrisBraunclaude
andauthored
Fold calendar attendee responses onto the event thread (#342)
* feat(google): parse attendee responses from METHOD:REPLY calendar parts * fix(google): dedupe ICS line unfolding and correct backslash-escape ordering Factor icsProp's and icsPropLine's shared RFC 5545 line-unfolding and regex into one matchIcsLine helper. Fix unescapeIcsText's two-pass replacement, which misread an escaped backslash followed by a literal 'n' (\\n) as a newline escape; a single-pass scan now handles all four escapes correctly. Adds tests locking the quoted-parameter colon-scoping invariant and the backslash-escape ordering fix. * feat(google): compose succinct notes for attendee responses * docs(twister): describe NewNote.unread's three read-state behaviours * feat(google): fold attendee responses onto the event thread Google emails the meeting organizer a separate notification for every guest response ("Declined: Team sync @ Tue Aug 4"), each importing as its own, mostly-boilerplate email thread. The Gmail sync now folds each response onto the calendar event's own thread as a short note instead, and only falls back to creating a standalone email thread when the event hasn't synced yet or when the conversation also carries real correspondence. * fix(google): keep preview and facets on the surviving message after folding RSVPs A mixed conversation (an RSVP notification plus a genuine human reply) kept computing its preview and classifier facets from the notification's headers and snippet even after that message's note was folded onto the event thread and dropped. Restrict both to a message whose note survived the fold, falling back to the previous behavior untouched when nothing was folded. * docs(twister): clarify NewNote.unread interaction with scoped notes * fix: keep Granola backfill from touching calendar-event read state Granola summaries attach as a note to the calendar event's own thread rather than creating a separate thread. During the initial backfill, the note carried an unread:false hint intended for its own thread — but since the note attaches to a thread Granola doesn't own, that hint would mark the calendar event's thread read instead. Drop it so backfilled summaries leave the event thread's read state untouched, as they always have. Also neutralizes a test fixture subject line in the Gmail connector's RSVP-fold tests to a generic placeholder, consistent with the other synthetic fixtures in that file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JQ7oQtGrvpDYqeEMVt1DRa --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c48f80f commit 24fed62

9 files changed

Lines changed: 904 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Changed: `NewNote.unread` now controls read state on the `saveNotes` path. Omitting it leaves the thread's read state untouched, `true` marks the thread unread for everyone but the note's author, and `false` marks it read for the connection owner so a two-way read sync converges. Previously the field had no effect on notes saved through `saveNotes`.

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

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import {
77
buildReactionMessage,
88
buildReplyMessage,
99
classifyCalendarThread,
10+
extractCalendarReplies,
1011
formatFromHeader,
1112
isSendableGmailReaction,
1213
stripQuotedReply,
1314
transformGmailThread,
1415
type AttachmentData,
16+
type CalendarReply,
1517
type GmailMessage,
1618
type GmailMessagePart,
1719
type GmailThread,
@@ -911,3 +913,205 @@ describe("classifyCalendarThread", () => {
911913
});
912914
});
913915

916+
describe("extractCalendarReplies", () => {
917+
/** Minimal well-typed GmailMessage wrapping a text/calendar payload. */
918+
function replyMessage(
919+
ics: string,
920+
opts: { id?: string; internalDate?: string; html?: string } = {}
921+
): GmailMessage {
922+
const parts: GmailMessagePart[] = [part("text/calendar", { data: ics })];
923+
if (opts.html) parts.unshift(part("text/html", { data: opts.html }));
924+
return {
925+
id: opts.id ?? "m1",
926+
threadId: "t1",
927+
labelIds: ["INBOX"],
928+
snippet: "snippet",
929+
historyId: "1",
930+
internalDate: opts.internalDate ?? "1700000000000",
931+
sizeEstimate: 500,
932+
payload: part("multipart/mixed", { parts }),
933+
};
934+
}
935+
936+
const declined = [
937+
"BEGIN:VCALENDAR",
938+
"METHOD:REPLY",
939+
"BEGIN:VEVENT",
940+
"UID:uid-1@google.com",
941+
"ATTENDEE;CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;CN=Beth Round:mailto:beth@example.test",
942+
"END:VEVENT",
943+
"END:VCALENDAR",
944+
].join("\r\n");
945+
946+
it("extracts a decline", () => {
947+
const [reply] = extractCalendarReplies([replyMessage(declined)]);
948+
expect(reply).toMatchObject({
949+
messageId: "m1",
950+
uid: "uid-1@google.com",
951+
partstat: "DECLINED",
952+
attendeeName: "Beth Round",
953+
attendeeEmail: "beth@example.test",
954+
occurrence: null,
955+
allDay: false,
956+
comment: null,
957+
});
958+
expect(reply.sourceCreatedAt.getTime()).toBe(1700000000000);
959+
});
960+
961+
it("extracts accepted and tentative", () => {
962+
const accepted = declined.replace("PARTSTAT=DECLINED", "PARTSTAT=ACCEPTED");
963+
const tentative = declined.replace("PARTSTAT=DECLINED", "PARTSTAT=TENTATIVE");
964+
expect(extractCalendarReplies([replyMessage(accepted)])[0].partstat).toBe("ACCEPTED");
965+
expect(extractCalendarReplies([replyMessage(tentative)])[0].partstat).toBe("TENTATIVE");
966+
});
967+
968+
it("ignores NEEDS-ACTION", () => {
969+
const pending = declined.replace("PARTSTAT=DECLINED", "PARTSTAT=NEEDS-ACTION");
970+
expect(extractCalendarReplies([replyMessage(pending)])).toEqual([]);
971+
});
972+
973+
it("ignores non-REPLY methods and messages with no ICS", () => {
974+
const request = declined.replace("METHOD:REPLY", "METHOD:REQUEST");
975+
expect(extractCalendarReplies([replyMessage(request)])).toEqual([]);
976+
expect(
977+
extractCalendarReplies([
978+
{ ...replyMessage(declined), payload: part("text/plain", { data: "hi" }) },
979+
])
980+
).toEqual([]);
981+
});
982+
983+
it("reads a timed RECURRENCE-ID as a UTC instant", () => {
984+
const ics = declined.replace(
985+
"END:VEVENT",
986+
"RECURRENCE-ID:20260804T140000Z\r\nEND:VEVENT"
987+
);
988+
const [reply] = extractCalendarReplies([replyMessage(ics)]);
989+
expect(reply.occurrence?.toISOString()).toBe("2026-08-04T14:00:00.000Z");
990+
expect(reply.allDay).toBe(false);
991+
});
992+
993+
it("reads an all-day RECURRENCE-ID", () => {
994+
const ics = declined.replace(
995+
"END:VEVENT",
996+
"RECURRENCE-ID;VALUE=DATE:20260804\r\nEND:VEVENT"
997+
);
998+
const [reply] = extractCalendarReplies([replyMessage(ics)]);
999+
expect(reply.occurrence?.toISOString()).toBe("2026-08-04T00:00:00.000Z");
1000+
expect(reply.allDay).toBe(true);
1001+
});
1002+
1003+
it("reads a floating RECURRENCE-ID with a TZID as UTC", () => {
1004+
// No offset is recoverable without a tz database; treating it as UTC keeps
1005+
// the formatted date correct for every occurrence except late evening.
1006+
const ics = declined.replace(
1007+
"END:VEVENT",
1008+
"RECURRENCE-ID;TZID=America/Toronto:20260804T100000\r\nEND:VEVENT"
1009+
);
1010+
const [reply] = extractCalendarReplies([replyMessage(ics)]);
1011+
expect(reply.occurrence?.toISOString()).toBe("2026-08-04T10:00:00.000Z");
1012+
});
1013+
1014+
it("prefers the COMMENT property for the personal note", () => {
1015+
const ics = declined.replace(
1016+
"END:VEVENT",
1017+
"COMMENT:Could we move this to Thursday?\r\nEND:VEVENT"
1018+
);
1019+
expect(extractCalendarReplies([replyMessage(ics)])[0].comment).toBe(
1020+
"Could we move this to Thursday?"
1021+
);
1022+
});
1023+
1024+
it("unfolds a COMMENT split across continuation lines", () => {
1025+
const ics = declined.replace(
1026+
"END:VEVENT",
1027+
"COMMENT:Could we move this\r\n to Thursday?\r\nEND:VEVENT"
1028+
);
1029+
expect(extractCalendarReplies([replyMessage(ics)])[0].comment).toBe(
1030+
"Could we move this to Thursday?"
1031+
);
1032+
});
1033+
1034+
it("unescapes RFC 5545 escapes in COMMENT", () => {
1035+
const ics = declined.replace(
1036+
"END:VEVENT",
1037+
"COMMENT:Line one\\nLine two\\, and more\r\nEND:VEVENT"
1038+
);
1039+
expect(extractCalendarReplies([replyMessage(ics)])[0].comment).toBe(
1040+
"Line one\nLine two, and more"
1041+
);
1042+
});
1043+
1044+
it("keeps an escaped backslash followed by a literal 'n' distinct from a \\n newline escape", () => {
1045+
const ics = declined.replace(
1046+
"END:VEVENT",
1047+
"COMMENT:Back\\\\nslash\r\nEND:VEVENT"
1048+
);
1049+
expect(extractCalendarReplies([replyMessage(ics)])[0].comment).toBe(
1050+
"Back\\nslash"
1051+
);
1052+
});
1053+
1054+
it("falls back to the ATTENDEE X-RESPONSE-COMMENT parameter", () => {
1055+
const ics = declined.replace(
1056+
"CN=Beth Round:",
1057+
'CN=Beth Round;X-RESPONSE-COMMENT="Sorry, conflict":'
1058+
);
1059+
expect(extractCalendarReplies([replyMessage(ics)])[0].comment).toBe(
1060+
"Sorry, conflict"
1061+
);
1062+
});
1063+
1064+
it("keeps a colon inside a quoted parameter value scoped to the parameter section", () => {
1065+
const ics = declined.replace(
1066+
"CN=Beth Round:",
1067+
'CN=Beth Round;X-RESPONSE-COMMENT="Back by 3:00":'
1068+
);
1069+
const [reply] = extractCalendarReplies([replyMessage(ics)]);
1070+
expect(reply.comment).toBe("Back by 3:00");
1071+
expect(reply.attendeeEmail).toBe("beth@example.test");
1072+
});
1073+
1074+
it("falls back to the email body's quoted note", () => {
1075+
const html =
1076+
"<div>Beth Round has declined this invitation with a note:<br>" +
1077+
'&quot;Could we move this to Thursday?&quot;</div>' +
1078+
'<div><a href="https://meet.google.com/abc">Join with Google Meet</a></div>';
1079+
expect(extractCalendarReplies([replyMessage(declined, { html })])[0].comment).toBe(
1080+
"Could we move this to Thursday?"
1081+
);
1082+
});
1083+
1084+
it("returns null comment when no source has one", () => {
1085+
const html = "<div>Beth Round has declined this invitation.</div>";
1086+
expect(extractCalendarReplies([replyMessage(declined, { html })])[0].comment).toBeNull();
1087+
});
1088+
1089+
it("falls back to the From display name when CN is absent", () => {
1090+
const ics = declined.replace(";CN=Beth Round:", ":");
1091+
const msg = replyMessage(ics);
1092+
msg.payload.headers = [{ name: "From", value: '"Beth Round" <beth@example.test>' }];
1093+
expect(extractCalendarReplies([msg])[0].attendeeName).toBe("Beth Round");
1094+
});
1095+
1096+
it("returns one descriptor per reply message in the conversation", () => {
1097+
const second = declined
1098+
.replace("PARTSTAT=DECLINED", "PARTSTAT=ACCEPTED")
1099+
.replace("beth@example.test", "sam@example.test");
1100+
const replies = extractCalendarReplies([
1101+
replyMessage(declined, { id: "m1" }),
1102+
replyMessage(second, { id: "m2" }),
1103+
]);
1104+
expect(replies.map((r) => [r.messageId, r.partstat])).toEqual([
1105+
["m1", "DECLINED"],
1106+
["m2", "ACCEPTED"],
1107+
]);
1108+
});
1109+
1110+
it("skips a reply with no resolvable UID or attendee email", () => {
1111+
const noUid = declined.replace("UID:uid-1@google.com\r\n", "");
1112+
const noAttendee = declined.replace(/^ATTENDEE.*\r\n/m, "");
1113+
expect(extractCalendarReplies([replyMessage(noUid)])).toEqual([]);
1114+
expect(extractCalendarReplies([replyMessage(noAttendee)])).toEqual([]);
1115+
});
1116+
});
1117+

0 commit comments

Comments
 (0)