From 4ed20708c9070d03dfe4ece17dc68b5f557739c6 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 6 Aug 2026 13:25:42 -0400 Subject: [PATCH 1/5] Export unescapeIcsText for connectors reading escaped ICS properties --- libs/rsvp-fold/src/ics-reply.test.ts | 18 +++++++++++++++++- libs/rsvp-fold/src/ics-reply.ts | 6 +++++- libs/rsvp-fold/src/index.ts | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/libs/rsvp-fold/src/ics-reply.test.ts b/libs/rsvp-fold/src/ics-reply.test.ts index 5e195b36..1bd89206 100644 --- a/libs/rsvp-fold/src/ics-reply.test.ts +++ b/libs/rsvp-fold/src/ics-reply.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseIcsReply } from "./ics-reply"; +import { parseIcsReply, unescapeIcsText } from "./ics-reply"; const FALLBACK = { name: null }; @@ -185,3 +185,19 @@ describe("parseIcsReply", () => { expect(bareReply?.occurrence).toBeNull(); }); }); + +describe("unescapeIcsText", () => { + it("un-escapes newlines, commas, semicolons and backslashes", () => { + expect(unescapeIcsText("line one\\nline two")).toBe("line one\nline two"); + expect(unescapeIcsText("uppercase\\Nform")).toBe("uppercase\nform"); + expect(unescapeIcsText("a\\, b\\; c")).toBe("a, b; c"); + expect(unescapeIcsText("back\\\\slash")).toBe("back\\slash"); + }); + + it("does not read an escaped backslash followed by 'n' as a newline", () => { + // `\\n` is a literal backslash then the letter n — a two-pass replacement + // would consume the second backslash as the start of its own \n escape. + expect(unescapeIcsText("path\\\\name")).toBe("path\\name"); + expect(unescapeIcsText("c:\\\\nope")).toBe("c:\\nope"); + }); +}); diff --git a/libs/rsvp-fold/src/ics-reply.ts b/libs/rsvp-fold/src/ics-reply.ts index 904c3526..3568c96d 100644 --- a/libs/rsvp-fold/src/ics-reply.ts +++ b/libs/rsvp-fold/src/ics-reply.ts @@ -84,8 +84,12 @@ function parseIcsDate(value: string): Date | null { * followed by a literal `n` (`\\n`) isn't misread as a newline escape — a * two-pass `\n`-then-`\\` replacement would consume the second backslash of * `\\` as if it started its own `\n` escape. + * + * Exported because connectors read escaped text out of properties this module + * does not parse for them (e.g. an invitation's `COMMENT`), and a second copy + * of this rule would undo the deduplication `matchIcsLine` established. */ -function unescapeIcsText(value: string): string { +export function unescapeIcsText(value: string): string { return value.replace(/\\([nN,;\\])/g, (_, ch: string) => ch === "n" || ch === "N" ? "\n" : ch ); diff --git a/libs/rsvp-fold/src/index.ts b/libs/rsvp-fold/src/index.ts index ca49cbdc..bc553d1f 100644 --- a/libs/rsvp-fold/src/index.ts +++ b/libs/rsvp-fold/src/index.ts @@ -9,4 +9,4 @@ export { type RsvpReply, } from "./rsvp-note"; -export { parseIcsReply, icsProp, type IcsReplyFallback } from "./ics-reply"; +export { parseIcsReply, icsProp, unescapeIcsText, type IcsReplyFallback } from "./ics-reply"; From dcc6c90bfa8f1daf00a607b963e05f191822c9c2 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 6 Aug 2026 13:34:45 -0400 Subject: [PATCH 2/5] Parse first-time calendar invitations out of a Gmail conversation --- connectors/google/src/mail/gmail-api.test.ts | 139 +++++++++++++++++++ connectors/google/src/mail/gmail-api.ts | 108 +++++++++++++- 2 files changed, 246 insertions(+), 1 deletion(-) diff --git a/connectors/google/src/mail/gmail-api.test.ts b/connectors/google/src/mail/gmail-api.test.ts index 2af51fb8..cb6196d6 100644 --- a/connectors/google/src/mail/gmail-api.test.ts +++ b/connectors/google/src/mail/gmail-api.test.ts @@ -7,6 +7,7 @@ import { buildReactionMessage, buildReplyMessage, classifyCalendarThread, + extractCalendarInvites, extractCalendarReplies, formatFromHeader, isSendableGmailReaction, @@ -1307,3 +1308,141 @@ describe("extractCalendarReplies", () => { }); }); +describe("extractCalendarInvites", () => { + function inviteIcs( + opts: { sequence?: number; uid?: string; comment?: string; method?: string } = {} + ): string { + const lines = [ + "BEGIN:VCALENDAR", + `METHOD:${opts.method ?? "REQUEST"}`, + "BEGIN:VEVENT", + `UID:${opts.uid ?? "uid-invite@google.com"}`, + "ORGANIZER;CN=Ada Organizer:mailto:ada@example.test", + "DTSTART:20260825T130000Z", + `SEQUENCE:${opts.sequence ?? 0}`, + ]; + if (opts.comment) lines.push(`COMMENT:${opts.comment}`); + lines.push("END:VEVENT", "END:VCALENDAR"); + return lines.join("\r\n"); + } + + function inviteMessage(id = "m1"): GmailMessage { + return { + id, + threadId: "t1", + labelIds: ["INBOX"], + snippet: "You have been invited", + historyId: "1", + internalDate: "1700000000000", + sizeEstimate: 500, + payload: part("multipart/mixed", { + headers: [ + ["From", "Ada Organizer "], + ["To", "me@example.com"], + ["Subject", "Invitation: Weekly sync @ Tue Aug 25, 2026"], + ], + parts: [ + part("text/html", { data: "

When: Tuesday

" }), + icsAttachmentPart(), + ], + }), + }; + } + + it("yields a descriptor for a bare invite (METHOD:REQUEST, SEQUENCE 0)", () => { + const message = inviteMessage(); + const invites = extractCalendarInvites( + [message], + new Map([["m1", inviteIcs()]]) + ); + + expect(invites).toEqual([ + { + messageId: "m1", + uid: "uid-invite@google.com", + organizerName: "Ada Organizer", + organizerEmail: "ada@example.test", + extraContent: null, + sourceCreatedAt: new Date(1700000000000), + }, + ]); + }); + + it("yields nothing for an update (SEQUENCE > 0)", () => { + const invites = extractCalendarInvites( + [inviteMessage()], + new Map([["m1", inviteIcs({ sequence: 2 })]]) + ); + expect(invites).toEqual([]); + }); + + it("yields nothing for a cancellation or a reply", () => { + expect( + extractCalendarInvites( + [inviteMessage()], + new Map([["m1", inviteIcs({ method: "CANCEL" })]]) + ) + ).toEqual([]); + expect( + extractCalendarInvites( + [inviteMessage()], + new Map([["m1", inviteIcs({ method: "REPLY" })]]) + ) + ).toEqual([]); + }); + + it("yields nothing when the ICS carries no UID", () => { + const noUid = inviteIcs().replace("UID:uid-invite@google.com\r\n", ""); + expect( + extractCalendarInvites([inviteMessage()], new Map([["m1", noUid]])) + ).toEqual([]); + }); + + it("yields nothing for a message with no calendar part", () => { + expect(extractCalendarInvites([inviteMessage()], new Map())).toEqual([]); + }); + + it("carries the ICS COMMENT as extraContent, RFC 5545 un-escaped", () => { + const invites = extractCalendarInvites( + [inviteMessage()], + new Map([ + ["m1", inviteIcs({ comment: "Bring the deck\\nand a laptop\\, please" })], + ]) + ); + expect(invites[0].extraContent).toBe("Bring the deck\nand a laptop, please"); + }); + + it("treats a whitespace-only COMMENT as no extra content", () => { + const invites = extractCalendarInvites( + [inviteMessage()], + new Map([["m1", inviteIcs({ comment: "\\n " })]]) + ); + expect(invites[0].extraContent).toBeNull(); + }); + + it("falls back to the ORGANIZER line when the From header has no display name", () => { + const message = inviteMessage(); + message.payload.headers = [ + { name: "From", value: "ada@example.test" }, + { name: "To", value: "me@example.com" }, + ]; + + const invites = extractCalendarInvites( + [message], + new Map([["m1", inviteIcs()]]) + ); + expect(invites[0].organizerName).toBe("Ada Organizer"); + }); + + it("returns one descriptor per invite message in a conversation", () => { + const invites = extractCalendarInvites( + [inviteMessage("m1"), inviteMessage("m2")], + new Map([ + ["m1", inviteIcs({ uid: "a@google.com" })], + ["m2", inviteIcs({ uid: "b@google.com" })], + ]) + ); + expect(invites.map((i) => i.uid)).toEqual(["a@google.com", "b@google.com"]); + }); +}); + diff --git a/connectors/google/src/mail/gmail-api.ts b/connectors/google/src/mail/gmail-api.ts index a1349797..f66edcd8 100644 --- a/connectors/google/src/mail/gmail-api.ts +++ b/connectors/google/src/mail/gmail-api.ts @@ -10,7 +10,7 @@ import type { import { markdownToPlainText } from "@plotday/twister/utils/markdown"; import { markdownToHtml } from "@plotday/twister/utils/markdown-html"; import { isNoReplySender } from "@plotday/twister/signals"; -import { icsProp, parseIcsReply } from "@plotday/rsvp-fold"; +import { icsProp, parseIcsReply, unescapeIcsText } from "@plotday/rsvp-fold"; export type GmailLabel = { @@ -986,6 +986,112 @@ export function extractCalendarReplies( return replies; } +/** + * One invitation parsed from a `METHOD:REQUEST` calendar part at `SEQUENCE 0` + * — a first-time invite, as opposed to an update (`SEQUENCE > 0`) or a + * cancellation. Google emails one per invited calendar; Plot folds it onto the + * event's own thread rather than importing it as a standalone email thread, + * because the event thread already renders the schedule, the guest list and + * the RSVP affordance that the notification only describes in prose. + */ +export type CalendarInvite = { + /** Gmail message id. The fold's note key and bookkeeping key. */ + messageId: string; + /** ICS UID — the event thread is addressed as `icaluid:`. */ + uid: string; + /** From-header display name, else the ORGANIZER `CN`, else null. */ + organizerName: string | null; + organizerEmail: string; + /** + * The organizer's note about this invitation, from the ICS `COMMENT`. + * + * RFC 5545 scopes `COMMENT` to the iCalendar transmission, which makes it + * exactly "what the invitation adds beyond the event" — the event's own body + * is `DESCRIPTION`, which the calendar sync has already written as the event + * thread's description note and which is deliberately not read here, so it + * can never double-post. + * + * Null for every stock Google invitation; the fold then writes no note at + * all. + */ + extraContent: string | null; + sourceCreatedAt: Date; +}; + +/** + * Every first-time invitation carried by a Gmail conversation. + * + * Deliberately separate from {@link classifyCalendarThread}, which answers a + * different question — "should this conversation *bundle* onto the event" — + * and for a bare invite must keep answering no. Bundling makes the mail link + * the event's thread, which would rewrite the event's title to + * `Invitation: … @ …` and re-author it. + */ +export function extractCalendarInvites( + messages: GmailMessage[], + icsByMessage: Map +): CalendarInvite[] { + const invites: CalendarInvite[] = []; + + for (const message of messages) { + const ics = icsByMessage.get(message.id); + if (!ics) continue; + + if ((icsProp(ics, "METHOD") ?? "").toUpperCase() !== "REQUEST") continue; + // SEQUENCE absent means 0 — RFC 5545's default for a first transmission. + if (parseInt(icsProp(ics, "SEQUENCE") ?? "0", 10) !== 0) continue; + + const uid = icsProp(ics, "UID"); + if (!uid) continue; + + const from = parseEmailAddress(getHeader(message, "From") ?? ""); + const organizerLine = icsProp(ics, "ORGANIZER"); + const organizerEmail = ( + from?.email ?? + organizerLine?.replace(/^mailto:/i, "") ?? + "" + ).trim(); + if (!organizerEmail) continue; + + // The From display name is the organizer as their own mail client named + // them; the ORGANIZER CN is the calendar system's copy. Prefer the former, + // fall back to the latter. + const cn = icsPropCn(ics, "ORGANIZER"); + const organizerName = from?.name || cn || null; + + const rawComment = icsProp(ics, "COMMENT"); + const comment = rawComment ? unescapeIcsText(rawComment).trim() : ""; + + invites.push({ + messageId: message.id, + uid, + organizerName, + organizerEmail, + extraContent: comment || null, + sourceCreatedAt: new Date(Number(message.internalDate)), + }); + } + + return invites; +} + +/** + * Read a `CN=` parameter off an ICS property line. `icsProp` returns only the + * value, and `@plotday/rsvp-fold` keeps its parameter parser private, so this + * reads the one parameter an invite needs rather than widening that module's + * surface for a single caller. + */ +function icsPropCn(ics: string, name: string): string | null { + const unfolded = ics.replace(/\r?\n[ \t]/g, ""); + const line = unfolded.match( + new RegExp(`^${name}((?:;[^:\\r\\n]*)?):`, "im") + ); + if (!line) return null; + const cn = line[1].match(/;CN=("([^"]*)"|[^;:]*)/i); + const value = cn ? (cn[2] !== undefined ? cn[2] : cn[1]) : ""; + return value.trim() || null; +} + /** * Locates the start of an Outlook-style "From: / Sent: / To: / Subject:" * reply header even when the field labels are not wrapped in `` or From 5718c781a413d99e32cf0d481095b8c7af17d8a5 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 6 Aug 2026 13:50:24 -0400 Subject: [PATCH 3/5] Record synced calendar UIDs and let the mail sync read them The Google Calendar sync now records an event-uid: marker for every upcoming event with attendees it saves. The Gmail sync gains an optional readCalendarState() bridge to check for that marker (mirroring the existing readMailState() bridge calendar already reads on the Google connector). Nothing consumes the new bridge yet - it lays the groundwork for the mail sync to recognize when an arriving invitation email already has a corresponding event thread. --- connectors/google/src/calendar/sync.test.ts | 152 ++++++++++++++++++++ connectors/google/src/calendar/sync.ts | 60 +++++++- connectors/google/src/google.ts | 3 + connectors/google/src/mail/sync.ts | 9 ++ 4 files changed, 223 insertions(+), 1 deletion(-) diff --git a/connectors/google/src/calendar/sync.test.ts b/connectors/google/src/calendar/sync.test.ts index 1f47a6d5..61726fde 100644 --- a/connectors/google/src/calendar/sync.test.ts +++ b/connectors/google/src/calendar/sync.test.ts @@ -2164,3 +2164,155 @@ describe("calendarHistoryFloor", () => { expect(oneYearAgo >= calendarHistoryFloor(now)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// event-uid markers — the calendar sync records `event-uid:` for every +// upcoming event with guests it saves, so the mail sync can later tell +// whether an arriving invitation email already has an event thread to fold +// onto (see readCalendarState on GmailSyncHost). +// --------------------------------------------------------------------------- + +describe("processCalendarEventsFn — event-uid markers", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const calendarId = "cal-1"; + const isoDaysFromNow = (n: number) => + new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString(); + + const toIcalUntil = (d: Date) => + d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; + + function upcomingEventWithAttendees(opts: { iCalUID: string; id: string }) { + return { + id: opts.id, + iCalUID: opts.iCalUID, + status: "confirmed" as const, + summary: "Upcoming meeting", + organizer: { email: "boss@example.test" }, + attendees: [ + { email: "boss@example.test", organizer: true }, + { email: "me@example.test", self: true }, + ], + start: { dateTime: isoDaysFromNow(1) }, + end: { dateTime: isoDaysFromNow(1) }, + }; + } + + function pastEventWithAttendees(opts: { iCalUID: string; id: string }) { + return { + id: opts.id, + iCalUID: opts.iCalUID, + status: "confirmed" as const, + summary: "Meeting that already happened", + organizer: { email: "boss@example.test" }, + attendees: [ + { email: "boss@example.test", organizer: true }, + { email: "me@example.test", self: true }, + ], + start: { dateTime: isoDaysFromNow(-2) }, + end: { dateTime: isoDaysFromNow(-2) }, + }; + } + + function upcomingSoloEvent(opts: { iCalUID: string; id: string }) { + return { + id: opts.id, + iCalUID: opts.iCalUID, + status: "confirmed" as const, + summary: "Solo focus block", + organizer: { email: "me@example.test", self: true }, + attendees: [], + start: { dateTime: isoDaysFromNow(1) }, + end: { dateTime: isoDaysFromNow(1) }, + }; + } + + function recurringEventWithAttendees(opts: { + iCalUID: string; + id: string; + recurrenceUntil: Date; + }) { + return { + id: opts.id, + iCalUID: opts.iCalUID, + status: "confirmed" as const, + summary: "Recurring standup", + organizer: { email: "boss@example.test" }, + attendees: [ + { email: "boss@example.test", organizer: true }, + { email: "me@example.test", self: true }, + ], + // Started in the past, still recurring. + start: { dateTime: isoDaysFromNow(-30) }, + end: { dateTime: isoDaysFromNow(-30) }, + recurrence: [ + `RRULE:FREQ=WEEKLY;UNTIL=${toIcalUntil(opts.recurrenceUntil)}`, + ], + }; + } + + it("records a marker per icaluid source for an upcoming event with attendees", async () => { + const host = makeFakeHost({ calendarId }); + vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([]))); + + await processCalendarEventsFn( + host, + [upcomingEventWithAttendees({ iCalUID: "uid-1", id: "ev-1" })], + calendarId, + false + ); + + expect(host.store.get("event-uid:uid-1")).toBe(true); + expect(host.store.get("event-uid:uid-1@google.com")).toBe(true); + }); + + it("records no marker for an event that has already ended", async () => { + const host = makeFakeHost({ calendarId }); + vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([]))); + + await processCalendarEventsFn( + host, + [pastEventWithAttendees({ iCalUID: "uid-past", id: "ev-2" })], + calendarId, + false + ); + + expect(host.store.get("event-uid:uid-past")).toBeUndefined(); + }); + + it("records no marker for a solo event with no attendees", async () => { + const host = makeFakeHost({ calendarId }); + vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([]))); + + await processCalendarEventsFn( + host, + [upcomingSoloEvent({ iCalUID: "uid-solo", id: "ev-3" })], + calendarId, + false + ); + + expect(host.store.get("event-uid:uid-solo")).toBeUndefined(); + }); + + it("records a marker for a recurring series whose UNTIL has not passed", async () => { + const host = makeFakeHost({ calendarId }); + vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([]))); + + await processCalendarEventsFn( + host, + [ + recurringEventWithAttendees({ + iCalUID: "uid-series", + id: "ev-4", + recurrenceUntil: new Date(Date.now() + 30 * 24 * 3600 * 1000), + }), + ], + calendarId, + false + ); + + expect(host.store.get("event-uid:uid-series")).toBe(true); + }); +}); diff --git a/connectors/google/src/calendar/sync.ts b/connectors/google/src/calendar/sync.ts index 622674c1..3ddb88e0 100644 --- a/connectors/google/src/calendar/sync.ts +++ b/connectors/google/src/calendar/sync.ts @@ -23,7 +23,11 @@ import { ConferencingProvider, } from "@plotday/twister"; import type { ScheduleContactStatus } from "@plotday/twister/schedule"; -import type { NewScheduleContact, NewScheduleOccurrence } from "@plotday/twister/schedule"; +import type { + NewSchedule, + NewScheduleContact, + NewScheduleOccurrence, +} from "@plotday/twister/schedule"; import type { Thread } from "@plotday/twister"; import type { WebhookRequest } from "@plotday/twister/tools/network"; @@ -177,6 +181,32 @@ export function buildEventSources(opts: { return sources; } +/** + * True when a schedule still lies ahead, so an invitation for it could still + * arrive and need folding. Bounds the `event-uid:` key space to invitable + * events rather than every event ever synced: on a backfill the great majority + * are in the past and get no marker. + * + * A recurring master is judged by its UNTIL rather than its DTSTART — the + * series began in the past but occurrences keep coming, so invitations for it + * are still live. A series with no UNTIL never ends. + * + * Typed against `Omit` rather than the bare + * `NewSchedule` — that's the actual shape of `NewLinkWithNotes.schedules` + * (link schedules are built before the thread they'll belong to exists). + */ +function isUpcomingSchedule( + s: Omit, + now: number +): boolean { + if (s.recurrenceRule) { + if (!s.recurrenceUntil) return true; + return new Date(s.recurrenceUntil).getTime() >= now; + } + const boundary = s.end ?? s.start; + return new Date(boundary).getTime() >= now; +} + /** * De-duplicate a note/link contact roster by email (case-insensitive), * keeping the first occurrence. The organizer is both surfaced via @@ -1252,6 +1282,34 @@ export async function processCalendarEventsFn( } } + // Record the iCalUIDs this page saved so the MAIL sync can tell whether an + // arriving invitation already has an event thread and can be folded away. + // Derived from the assembled links rather than written per event-processing + // branch, so every path that produces a link (master, instance, cancellation + // reversal) is covered by one block and one round-trip. + // + // Narrowed to events that have guests and have not finished: an invitation + // only exists for an event with attendees, and one for an event that has + // already happened does not need folding. Without the narrowing the key + // space would grow with the user's entire calendar history. + { + const now = Date.now(); + const markers: [key: string, value: unknown][] = []; + for (const link of linksBySource.values()) { + const hasGuests = (link.accessContacts?.length ?? 0) > 1; + if (!hasGuests) continue; + const upcoming = (link.schedules ?? []).some((s) => + isUpcomingSchedule(s, now) + ); + if (!upcoming) continue; + for (const source of link.sources ?? []) { + if (!source.startsWith("icaluid:")) continue; + markers.push([`event-uid:${source.slice("icaluid:".length)}`, true]); + } + } + if (markers.length > 0) await host.setMany(markers); + } + const batch = Array.from(linksBySource.values()); if (batch.length > 0) { try { diff --git a/connectors/google/src/google.ts b/connectors/google/src/google.ts index 86d51de5..ddb0a662 100644 --- a/connectors/google/src/google.ts +++ b/connectors/google/src/google.ts @@ -649,6 +649,9 @@ export class Google extends Connector { setMany: (entries) => self._mailHostSetMany(entries), get: (key: string) => self._mailHostGet(key), clear: (key) => self._mailHostClear(key), + // Read into the CALENDAR namespace so the mail sync can check for an + // `event-uid:` marker recorded by the calendar sync. + readCalendarState: (key) => self._calendarHostGet(key), tools: { // eslint-disable-next-line @typescript-eslint/no-explicit-any integrations: self.tools.integrations as any, diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index 66988547..92b85e25 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -285,6 +285,15 @@ export interface GmailSyncHost { get(key: string): Promise; /** Delete a persisted value. */ clear(key: string): Promise; + /** + * Optional read into the CALENDAR namespace's state, used to check for an + * `event-uid:` marker recorded when the calendar sync saved the event + * an arriving invitation refers to. The mirror of `CalendarSyncHost`'s + * `readMailState`. Absent on hosts that don't wire mail and calendar + * together (a standalone Gmail connector, fake hosts in tests) — treated as + * "no event thread known", which keeps the invitation as an email thread. + */ + readCalendarState?(key: string): Promise; tools: { integrations: { From a7a7b9ad546fa063294488cbdf64908939295643 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 6 Aug 2026 13:59:04 -0400 Subject: [PATCH 4/5] Fold a first-time calendar invitation onto the event's own thread A Google Calendar invitation email previously imported as its own email thread even when the event had already synced, duplicating a thread that already renders the schedule, guest list and RSVP affordance. First-time invitations (ICS METHOD:REQUEST at SEQUENCE 0) are now folded onto the event's own thread instead: the organizer's ICS COMMENT (if any) is attached there as a note, and no separate email thread is created. If the event hasn't synced yet, the invitation keeps its email thread and records a pending marker so it can be reconciled once the event arrives. Invitation updates (SEQUENCE > 0) are unaffected and continue to bundle onto the event thread via the existing calendar-thread bundling path. --- connectors/google/src/mail/sync.test.ts | 202 ++++++++++++++++++++++++ connectors/google/src/mail/sync.ts | 105 +++++++++--- 2 files changed, 283 insertions(+), 24 deletions(-) diff --git a/connectors/google/src/mail/sync.test.ts b/connectors/google/src/mail/sync.test.ts index 16470afd..1f55484e 100644 --- a/connectors/google/src/mail/sync.test.ts +++ b/connectors/google/src/mail/sync.test.ts @@ -85,6 +85,7 @@ function makeHost(): { host: GmailSyncHost; store: Map } { clear: vi.fn(async (key: string) => { store.delete(key); }), + readCalendarState: vi.fn(async () => null), tools: { integrations: { get: vi.fn(async () => ({ token: "tok", scopes: [] })), @@ -1330,6 +1331,207 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", () }); }); +describe("processEmailThreadsFn — bare invitations fold onto the event", () => { + function inviteIcs(opts: { uid?: string; comment?: string; sequence?: number } = {}) { + const lines = [ + "BEGIN:VCALENDAR", + "METHOD:REQUEST", + "BEGIN:VEVENT", + `UID:${opts.uid ?? "uid-invite@google.com"}`, + "ORGANIZER;CN=Ada Organizer:mailto:ada@example.test", + "DTSTART:20260825T130000Z", + `SEQUENCE:${opts.sequence ?? 0}`, + ]; + if (opts.comment) lines.push(`COMMENT:${opts.comment}`); + lines.push("END:VEVENT", "END:VCALENDAR"); + return lines.join("\r\n"); + } + + /** A Gmail conversation carrying one invitation notification. */ + function inviteThread( + threadId: string, + ics: string, + opts: { withPlainReply?: boolean } = {} + ): GmailThread { + const invite: GmailMessage = { + id: `${threadId}-msg-1`, + threadId, + labelIds: ["INBOX"], + snippet: "You have been invited to Weekly sync", + historyId: "1", + internalDate: "1700000000000", + sizeEstimate: 500, + payload: part("multipart/mixed", { + headers: [ + ["From", "Ada Organizer "], + ["To", "me@example.com"], + ["Subject", "Invitation: Weekly sync @ Tue Aug 25, 2026"], + ], + parts: [ + part("text/html", { data: "

When: Tuesday

" }), + icsAttachmentPart(ics), + ], + }), + }; + const messages = [invite]; + if (opts.withPlainReply) { + messages.push({ + id: `${threadId}-msg-2`, + threadId, + labelIds: ["INBOX"], + snippet: "See you there", + historyId: "2", + internalDate: "1700000060000", + sizeEstimate: 200, + payload: part("text/plain", { + data: "See you there.", + headers: [ + ["From", "Ada Organizer "], + ["To", "me@example.com"], + ["Subject", "Re: Invitation: Weekly sync"], + ], + }), + }); + } + return { id: threadId, historyId: "1", messages }; + } + + /** Marks the event as already synced by the calendar product. */ + function withEventKnown(host: GmailSyncHost, uid = "uid-invite@google.com") { + (host.readCalendarState as ReturnType).mockImplementation( + async (key: string) => (key === `event-uid:${uid}` ? true : null) + ); + } + + it("saves no email link and no note when the event is already synced", async () => { + const { host } = makeHost(); + withEventKnown(host); + const { notes, links } = captureSaves(host); + + await processEmailThreadsFn( + host, + [inviteThread("inv-plain", inviteIcs())], + false, + "INBOX" + ); + + // The event thread already shows the schedule, guests and RSVP; the + // notification only describes them in prose. + expect(notes).toHaveLength(0); + expect(links).toHaveLength(0); + }); + + it("writes the organizer's ICS COMMENT as a note on the event thread", async () => { + const { host } = makeHost(); + withEventKnown(host); + const { notes, links } = captureSaves(host); + + await processEmailThreadsFn( + host, + [inviteThread("inv-comment", inviteIcs({ comment: "Bring the deck" }))], + false, + "INBOX" + ); + + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ + thread: { source: "icaluid:uid-invite@google.com" }, + key: "invite:inv-comment-msg-1", + content: "Bring the deck", + contentType: "markdown", + created: new Date(1700000000000), + unread: true, + author: { email: "ada@example.test", name: "Ada Organizer" }, + deferUntilThread: true, + }); + expect(links).toHaveLength(0); + }); + + it("keeps the email thread and records a pending key when no event is known", async () => { + const { host, store } = makeHost(); + const { notes, links } = captureSaves(host); + + await processEmailThreadsFn( + host, + [inviteThread("inv-orphan", inviteIcs())], + false, + "INBOX" + ); + + // No calendar for this event: the invitation must not vanish. + expect(links).toHaveLength(1); + expect(notes).toHaveLength(0); + expect(store.get("invite-wait:uid-invite@google.com")).toMatchObject({ + gmailThreadId: "inv-orphan", + }); + }); + + it("does not re-raise unread when the same invitation is re-processed", async () => { + const { host } = makeHost(); + withEventKnown(host); + const { notes } = captureSaves(host); + const thread = inviteThread("inv-replay", inviteIcs({ comment: "Bring the deck" })); + + await processEmailThreadsFn(host, [thread], false, "INBOX"); + await processEmailThreadsFn(host, [thread], false, "INBOX"); + + // Second pass is a Gmail history replay: the note upserts by key, but + // re-applying `unread` would drag a read event thread back to unread. + expect(notes).toHaveLength(1); + }); + + it("sets unread false during an initial backfill", async () => { + const { host } = makeHost(); + withEventKnown(host); + const { notes } = captureSaves(host); + + await processEmailThreadsFn( + host, + [inviteThread("inv-initial", inviteIcs({ comment: "Bring the deck" }))], + true, + "INBOX" + ); + + expect(notes[0]).toMatchObject({ unread: false }); + }); + + it("keeps a mixed conversation's thread with the invitation dropped", async () => { + const { host } = makeHost(); + withEventKnown(host); + const { links } = captureSaves(host); + + await processEmailThreadsFn( + host, + [inviteThread("inv-mixed", inviteIcs(), { withPlainReply: true })], + false, + "INBOX" + ); + + expect(links).toHaveLength(1); + const noteKeys = (links[0].notes ?? []).map((n) => (n as { key: string }).key); + expect(noteKeys).toEqual(["inv-mixed-msg-2"]); + // The preview must come from the surviving human message, not the folded + // notification. + expect(links[0].preview).toContain("See you there"); + }); + + it("leaves an update (SEQUENCE > 0) to the existing bundling path", async () => { + const { host } = makeHost(); + withEventKnown(host); + const { links } = captureSaves(host); + + await processEmailThreadsFn( + host, + [inviteThread("inv-update", inviteIcs({ sequence: 3 }))], + false, + "INBOX" + ); + + expect(links).toHaveLength(1); + expect(links[0].sources).toContain("icaluid:uid-invite@google.com"); + }); +}); + /** A single-message GmailThread carrying `labels`, with a plain-text body. */ function labelledThread(threadId: string, labels: string[]): GmailThread { const message: GmailMessage = { diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index 92b85e25..601a65ed 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -52,6 +52,7 @@ import { classifyCalendarThread, collectAttachments, extractBody, + extractCalendarInvites, extractCalendarReplies, formatFromHeader, getHeader, @@ -1606,34 +1607,90 @@ async function saveTransformedThread( // cost of losing the response outright for calendar-less recipients. foldedMessageIds.add(reply.messageId); } + } + + // A first-time invitation says nothing the event's own thread does not + // already render: schedule, guest list and RSVP are all there, and the + // notification only describes them in prose. Fold it away when we know the + // event has synced, and drop its note so an invitation-only conversation + // never becomes an email thread. + // + // Deliberately a fold and not a bundle. Adding `icaluid:` to the mail + // link's sources would make the mail link the event's thread and rewrite + // its title to "Invitation: … @ …". + const invites = extractCalendarInvites(thread.messages ?? [], icsByMessage); + for (const invite of invites) { + const foldedKey = `folded-invite:${invite.uid}:${invite.messageId}`; + if (await host.get(foldedKey)) { + foldedMessageIds.add(invite.messageId); + continue; + } + + // No bridge, or no marker, means no event thread is known for this UID: + // the calendar channel is off, the event lives on another provider, or + // the mail simply arrived first. Keep the email thread — an invitation + // must never be silently dropped — and leave a key the calendar sync + // retracts against if the event turns up later. + const known = await host.readCalendarState?.( + `event-uid:${invite.uid}` + ); + if (!known) { + await host.set(`invite-wait:${invite.uid}`, { + gmailThreadId: thread.id, + at: new Date().toISOString(), + }); + continue; + } - if (foldedMessageIds.size > 0) { - plotThread.notes = plotThread.notes.filter((note) => { - const noteKey = "key" in note ? (note as { key: string }).key : null; - return !noteKey || !foldedMessageIds.has(noteKey); + if (invite.extraContent) { + await host.tools.integrations.saveNote({ + thread: { source: `icaluid:${invite.uid}` }, + key: `invite:${invite.messageId}`, + content: invite.extraContent, + contentType: "markdown", + created: invite.sourceCreatedAt, + author: { + email: invite.organizerEmail, + ...(invite.organizerName ? { name: invite.organizerName } : {}), + }, + // Explicit on both paths: attaching a note already surfaces the + // thread as unread for everyone but its author, so only an explicit + // false overrides that during a backfill. + unread: !initialSync, + deferUntilThread: true, }); + } - // The preview (set from thread.messages[0].snippet in - // transformGmailThread) may have come from the message we just - // folded away. Recompute it from the first surviving note's own - // message so a mixed conversation previews the human reply, not the - // RSVP notification that's no longer part of this thread. - const previewMessageId = thread.messages?.[0]?.id; - if (previewMessageId && foldedMessageIds.has(previewMessageId)) { - const firstSurvivingNote = plotThread.notes[0]; - const firstSurvivingKey = - firstSurvivingNote && "key" in firstSurvivingNote - ? (firstSurvivingNote as { key: string }).key - : null; - const firstSurvivingMessage = firstSurvivingKey - ? thread.messages?.find((m) => m.id === firstSurvivingKey) + foldedMessageIds.add(invite.messageId); + await host.set(foldedKey, true); + } + + if (foldedMessageIds.size > 0) { + plotThread.notes = plotThread.notes.filter((note) => { + const noteKey = "key" in note ? (note as { key: string }).key : null; + return !noteKey || !foldedMessageIds.has(noteKey); + }); + + // The preview (set from thread.messages[0].snippet in + // transformGmailThread) may have come from the message we just + // folded away. Recompute it from the first surviving note's own + // message so a mixed conversation previews the human reply, not the + // RSVP notification that's no longer part of this thread. + const previewMessageId = thread.messages?.[0]?.id; + if (previewMessageId && foldedMessageIds.has(previewMessageId)) { + const firstSurvivingNote = plotThread.notes[0]; + const firstSurvivingKey = + firstSurvivingNote && "key" in firstSurvivingNote + ? (firstSurvivingNote as { key: string }).key : null; - plotThread.preview = - firstSurvivingMessage?.snippet || - (firstSurvivingNote as { content?: string } | undefined) - ?.content || - null; - } + const firstSurvivingMessage = firstSurvivingKey + ? thread.messages?.find((m) => m.id === firstSurvivingKey) + : null; + plotThread.preview = + firstSurvivingMessage?.snippet || + (firstSurvivingNote as { content?: string } | undefined) + ?.content || + null; } } From 7a12b8512306da5e4474c3c9003d8f0d05f15fff Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 6 Aug 2026 14:12:40 -0400 Subject: [PATCH 5/5] Retract an invitation's email thread when its event syncs later When a Google Calendar invitation email arrives before its event has synced, the connector previously had no way to know a matching event was coming, so it kept the invitation as its own email thread rather than risk silently dropping it. Once the event does sync, that email thread is now redundant. The calendar sync now checks, for every event it saves, whether there's a pending invitation waiting on it, and if so archives that invitation's email link so the event's own thread is the single source of truth. A pending invitation older than 7 days is treated as stale and cleared without being retracted, rather than lingering indefinitely. --- connectors/google/src/calendar/sync.test.ts | 143 +++++++++++++++++--- connectors/google/src/calendar/sync.ts | 45 +++++- connectors/google/src/google.ts | 5 +- 3 files changed, 173 insertions(+), 20 deletions(-) diff --git a/connectors/google/src/calendar/sync.test.ts b/connectors/google/src/calendar/sync.test.ts index 61726fde..b2b7e6a8 100644 --- a/connectors/google/src/calendar/sync.test.ts +++ b/connectors/google/src/calendar/sync.test.ts @@ -110,6 +110,16 @@ function makeFakeHost(overrides?: { clear: async (key) => { storeMap.delete(key); }, + // Defaults to "no marker" (mirrors hosts that don't wire mail/calendar + // together) but is a vi.fn so tests can `.mockImplementation(...)` it + // or read the pending invitation straight from `store`. + readMailState: vi.fn(async (_key: string) => null) as any, + // Deletes from the same store map tests seed `invite-wait:` keys into + // (mirrors readMailState above), while still being a vi.fn tests can + // assert calls against. + clearMailState: vi.fn(async (key: string) => { + storeMap.delete(key); + }) as any, tools: { integrations: { @@ -120,6 +130,8 @@ function makeFakeHost(overrides?: { channelSyncCompleted: async (channelId) => { syncCompletedCalls.push(channelId); }, + // Spy so tests can assert an invitation's email link was retracted. + archiveLinks: vi.fn(async (_filter: any) => {}), }, googleContacts: { // Minimal stub — enrichLinkContactsFromGoogle is best-effort @@ -2165,6 +2177,31 @@ describe("calendarHistoryFloor", () => { }); }); +// --------------------------------------------------------------------------- +// Shared fixtures for the event-uid marker / pending-invitation-retract +// suites below — hoisted to module scope so both describe blocks can build +// the same "upcoming event with attendees" shape. +// --------------------------------------------------------------------------- + +const isoDaysFromNow = (n: number) => + new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString(); + +function upcomingEventWithAttendees(opts: { iCalUID: string; id: string }) { + return { + id: opts.id, + iCalUID: opts.iCalUID, + status: "confirmed" as const, + summary: "Upcoming meeting", + organizer: { email: "boss@example.test" }, + attendees: [ + { email: "boss@example.test", organizer: true }, + { email: "me@example.test", self: true }, + ], + start: { dateTime: isoDaysFromNow(1) }, + end: { dateTime: isoDaysFromNow(1) }, + }; +} + // --------------------------------------------------------------------------- // event-uid markers — the calendar sync records `event-uid:` for every // upcoming event with guests it saves, so the mail sync can later tell @@ -2178,28 +2215,10 @@ describe("processCalendarEventsFn — event-uid markers", () => { }); const calendarId = "cal-1"; - const isoDaysFromNow = (n: number) => - new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString(); const toIcalUntil = (d: Date) => d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; - function upcomingEventWithAttendees(opts: { iCalUID: string; id: string }) { - return { - id: opts.id, - iCalUID: opts.iCalUID, - status: "confirmed" as const, - summary: "Upcoming meeting", - organizer: { email: "boss@example.test" }, - attendees: [ - { email: "boss@example.test", organizer: true }, - { email: "me@example.test", self: true }, - ], - start: { dateTime: isoDaysFromNow(1) }, - end: { dateTime: isoDaysFromNow(1) }, - }; - } - function pastEventWithAttendees(opts: { iCalUID: string; id: string }) { return { id: opts.id, @@ -2316,3 +2335,91 @@ describe("processCalendarEventsFn — event-uid markers", () => { expect(host.store.get("event-uid:uid-series")).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// pending invitation retract — an invitation that arrived before its event +// had synced was kept as an email thread (Task 4, mail/sync.ts) and left an +// `invite-wait:` marker behind. Once the calendar sync saves the event +// the marker refers to, it must retract that now-redundant email thread and +// clear the marker either way (acted on, or aged out past the 7-day window). +// --------------------------------------------------------------------------- + +describe("pending invitation retract", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** + * Thin adapter over makeFakeHost() exposing the pieces these tests need — + * not a second host factory, just convenient destructuring of the one. + */ + function makeHost() { + const host = makeFakeHost({ calendarId: "cal-1" }); + return { + host, + store: host.store, + archiveLinks: host.tools.integrations.archiveLinks as ReturnType< + typeof vi.fn + >, + }; + } + + it("archives the invitation's email link once the event syncs", async () => { + const { host, archiveLinks } = makeHost(); + (host.readMailState as ReturnType).mockImplementation( + async (key: string) => + key === "invite-wait:uid-1" + ? { gmailThreadId: "gmail-thread-1", at: new Date().toISOString() } + : null + ); + + await processCalendarEventsFn( + host, + [upcomingEventWithAttendees({ iCalUID: "uid-1", id: "ev-1" })], + "cal-1", + false + ); + + expect(archiveLinks).toHaveBeenCalledWith({ + meta: { threadId: "gmail-thread-1" }, + }); + // Cleared on the acted-on path too — a lingering marker would otherwise + // re-trigger archiveLinks on every future sync of this event. + expect(host.clearMailState).toHaveBeenCalledWith("invite-wait:uid-1"); + }); + + it("archives nothing when no invitation is pending", async () => { + const { host, archiveLinks } = makeHost(); + + await processCalendarEventsFn( + host, + [upcomingEventWithAttendees({ iCalUID: "uid-2", id: "ev-2" })], + "cal-1", + false + ); + + expect(archiveLinks).not.toHaveBeenCalled(); + }); + + it("ignores and clears a pending key older than the 7-day window", async () => { + const { host, store, archiveLinks } = makeHost(); + const stale = new Date(Date.now() - 8 * 24 * 3600 * 1000).toISOString(); + store.set("invite-wait:uid-3", { gmailThreadId: "old-thread", at: stale }); + (host.readMailState as ReturnType).mockImplementation( + async (key: string) => store.get(key) ?? null + ); + + await processCalendarEventsFn( + host, + [upcomingEventWithAttendees({ iCalUID: "uid-3", id: "ev-3" })], + "cal-1", + false + ); + + expect(archiveLinks).not.toHaveBeenCalled(); + + // Cleared even though nothing was archived — the store has no native + // expiry, so this is where the 7-day TTL is actually enforced. + expect(store.get("invite-wait:uid-3")).toBeUndefined(); + }); +}); diff --git a/connectors/google/src/calendar/sync.ts b/connectors/google/src/calendar/sync.ts index 3ddb88e0..4db1ce96 100644 --- a/connectors/google/src/calendar/sync.ts +++ b/connectors/google/src/calendar/sync.ts @@ -67,6 +67,12 @@ export interface CalendarSyncHost { * fake hosts in tests) — treated as "no cancel email seen". */ readMailState?(key: string): Promise; + /** + * Optional clear into the MAIL namespace's state, used to retire an + * `invite-wait:` marker once the event it refers to has synced (acted + * on or aged out). Absent on hosts that don't wire mail/calendar together. + */ + clearMailState?(key: string): Promise; tools: { integrations: { @@ -78,6 +84,16 @@ export interface CalendarSyncHost { saveLinks(links: NewLinkWithNotes[]): Promise; /** Signal that the initial backfill for a channel has finished. */ channelSyncCompleted(channelId: string): Promise; + /** + * Archive this connector's links matching a filter. Used to retract an + * invitation's email thread once the event it referred to has synced. + */ + archiveLinks(filter: { + channelId?: string; + type?: string; + status?: string; + meta?: Record; + }): Promise; }; googleContacts: GoogleContacts; store: { @@ -1295,6 +1311,7 @@ export async function processCalendarEventsFn( { const now = Date.now(); const markers: [key: string, value: unknown][] = []; + const markedUids = new Set(); for (const link of linksBySource.values()) { const hasGuests = (link.accessContacts?.length ?? 0) > 1; if (!hasGuests) continue; @@ -1304,10 +1321,36 @@ export async function processCalendarEventsFn( if (!upcoming) continue; for (const source of link.sources ?? []) { if (!source.startsWith("icaluid:")) continue; - markers.push([`event-uid:${source.slice("icaluid:".length)}`, true]); + const uid = source.slice("icaluid:".length); + markers.push([`event-uid:${uid}`, true]); + markedUids.add(uid); } } if (markers.length > 0) await host.setMany(markers); + + // An invitation that arrived before its event had synced was kept as an + // email thread (there was no way to know the event was coming). Now that + // the event is here, retract it. `meta.threadId` is on every Gmail link; + // ArchiveLinkFilter has no `source` field, so meta containment is how a + // single link is targeted. + for (const uid of markedUids) { + const pending = await host.readMailState?.<{ + gmailThreadId: string; + at: string; + }>(`invite-wait:${uid}`); + if (!pending) continue; + + const ageMs = Date.now() - new Date(pending.at).getTime(); + const withinWindow = ageMs < 7 * 24 * 60 * 60 * 1000; + if (withinWindow && pending.gmailThreadId) { + await host.tools.integrations.archiveLinks({ + meta: { threadId: pending.gmailThreadId }, + }); + } + // Cleared either way: acted on, or aged out. The store has no native + // expiry, so `at` is the TTL and this is where it is enforced. + await host.clearMailState?.(`invite-wait:${uid}`); + } } const batch = Array.from(linksBySource.values()); diff --git a/connectors/google/src/google.ts b/connectors/google/src/google.ts index ddb0a662..c123baa8 100644 --- a/connectors/google/src/google.ts +++ b/connectors/google/src/google.ts @@ -286,8 +286,11 @@ export class Google extends Connector { get: (key: string) => self._calendarHostGet(key), clear: (key) => self._calendarHostClear(key), // Read into the MAIL namespace so the calendar sync can check for a - // `cancel-email:` marker recorded by the mail sync (Plan B). + // `cancel-email:` marker recorded by the mail sync (Plan B), or an + // `invite-wait:` marker to retract once the event syncs. readMailState: (key) => self._mailHostGet(key), + // Clear an `invite-wait:` marker once retracted or aged out. + clearMailState: (key) => self._mailHostClear(key), tools: { integrations: self.tools.integrations as any, googleContacts: self.tools.googleContacts,