diff --git a/connectors/google/src/calendar/sync.test.ts b/connectors/google/src/calendar/sync.test.ts index 1f47a6d5..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 @@ -2164,3 +2176,250 @@ describe("calendarHistoryFloor", () => { expect(oneYearAgo >= calendarHistoryFloor(now)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// 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 +// 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 toIcalUntil = (d: Date) => + d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; + + 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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 622674c1..4db1ce96 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"; @@ -63,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: { @@ -74,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: { @@ -177,6 +197,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 +1298,61 @@ 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][] = []; + const markedUids = new Set(); + 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; + 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()); if (batch.length > 0) { try { diff --git a/connectors/google/src/google.ts b/connectors/google/src/google.ts index 86d51de5..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, @@ -649,6 +652,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/gmail-api.test.ts b/connectors/google/src/mail/gmail-api.test.ts index b4783ce9..5b190872 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,6 +1308,144 @@ 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"]); + }); +}); + describe("transformGmailThread inline images", () => { /** * A `multipart/related` message: the HTML body plus one image part carried diff --git a/connectors/google/src/mail/gmail-api.ts b/connectors/google/src/mail/gmail-api.ts index b8b18cf5..7bbdafbf 100644 --- a/connectors/google/src/mail/gmail-api.ts +++ b/connectors/google/src/mail/gmail-api.ts @@ -14,7 +14,7 @@ import { normalizeContentId, referencedContentIds, } from "@plotday/twister/signals"; -import { icsProp, parseIcsReply } from "@plotday/rsvp-fold"; +import { icsProp, parseIcsReply, unescapeIcsText } from "@plotday/rsvp-fold"; export type GmailLabel = { @@ -990,6 +990,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 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 66988547..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, @@ -285,6 +286,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: { @@ -1597,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; } } 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";