diff --git a/connectors/google/src/mail/rsvp-note.test.ts b/connectors/google/src/mail/rsvp-note.test.ts index f95df8ee..a243e63d 100644 --- a/connectors/google/src/mail/rsvp-note.test.ts +++ b/connectors/google/src/mail/rsvp-note.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { CalendarReply } from "./gmail-api"; -import { composeRsvpNote, shouldMarkUnread } from "./rsvp-note"; +import { composeRsvpNote, shouldEmitRsvpNote, priorRsvpKey } from "./rsvp-note"; function reply(overrides: Partial = {}): CalendarReply { return { @@ -80,18 +80,78 @@ describe("composeRsvpNote", () => { }); }); -describe("shouldMarkUnread", () => { - it("marks declines and tentative responses unread", () => { - expect(shouldMarkUnread(reply({ partstat: "DECLINED" }), false)).toBe(true); - expect(shouldMarkUnread(reply({ partstat: "TENTATIVE" }), false)).toBe(true); +describe("shouldEmitRsvpNote", () => { + it("emits for a decline or a tentative", () => { + expect(shouldEmitRsvpNote(reply({ partstat: "DECLINED" }), false)).toBe(true); + expect(shouldEmitRsvpNote(reply({ partstat: "TENTATIVE" }), false)).toBe(true); }); - it("leaves read state untouched for an accept", () => { - expect(shouldMarkUnread(reply({ partstat: "ACCEPTED" }), false)).toBe(false); + it("suppresses a bare acceptance", () => { + expect(shouldEmitRsvpNote(reply({ partstat: "ACCEPTED" }), false)).toBe(false); }); - it("never marks unread during the initial backfill", () => { - expect(shouldMarkUnread(reply({ partstat: "DECLINED" }), true)).toBe(false); - expect(shouldMarkUnread(reply({ partstat: "TENTATIVE" }), true)).toBe(false); + it("emits an acceptance that carries a comment", () => { + expect( + shouldEmitRsvpNote( + reply({ partstat: "ACCEPTED", comment: "Sounds good, I'll bring the deck" }), + false + ) + ).toBe(true); + }); + + it("emits an acceptance that follows a decline or tentative", () => { + expect(shouldEmitRsvpNote(reply({ partstat: "ACCEPTED" }), true)).toBe(true); + }); + + it("treats an empty comment as no comment", () => { + expect(shouldEmitRsvpNote(reply({ partstat: "ACCEPTED", comment: "" }), false)).toBe( + false + ); + }); +}); + +describe("priorRsvpKey", () => { + it("scopes the key to the event and the attendee", () => { + expect(priorRsvpKey("uid-1@google.com", "beth@example.test", null)).toBe( + "rsvp:uid-1@google.com:series:beth@example.test" + ); + }); + + it("normalises attendee case so a re-cased address hits the same key", () => { + expect(priorRsvpKey("uid-1@google.com", "Beth@Example.Test", null)).toBe( + priorRsvpKey("uid-1@google.com", "beth@example.test", null) + ); + }); + + it("uses the literal 'series' segment for a series-wide reply", () => { + expect(priorRsvpKey("uid-1@google.com", "beth@example.test", null)).toBe( + "rsvp:uid-1@google.com:series:beth@example.test" + ); + }); + + it("scopes the key to a single occurrence when the reply targets one", () => { + const occurrence = new Date("2026-08-04T14:00:00Z"); + expect(priorRsvpKey("uid-1@google.com", "beth@example.test", occurrence)).toBe( + `rsvp:uid-1@google.com:${occurrence.toISOString()}:beth@example.test` + ); + }); + + it("gives two different occurrences of the same uid+attendee different keys", () => { + const aug4 = new Date("2026-08-04T14:00:00Z"); + const aug18 = new Date("2026-08-18T14:00:00Z"); + const keyAug4 = priorRsvpKey("uid-1@google.com", "beth@example.test", aug4); + const keyAug18 = priorRsvpKey("uid-1@google.com", "beth@example.test", aug18); + expect(keyAug4).not.toBe(keyAug18); + }); + + it("gives an occurrence-scoped reply a different key from the series-wide key", () => { + const occurrence = new Date("2026-08-04T14:00:00Z"); + const seriesKey = priorRsvpKey("uid-1@google.com", "beth@example.test", null); + const occurrenceKey = priorRsvpKey( + "uid-1@google.com", + "beth@example.test", + occurrence + ); + expect(seriesKey).not.toBe(occurrenceKey); }); }); diff --git a/connectors/google/src/mail/rsvp-note.ts b/connectors/google/src/mail/rsvp-note.ts index 00708193..7e4bd026 100644 --- a/connectors/google/src/mail/rsvp-note.ts +++ b/connectors/google/src/mail/rsvp-note.ts @@ -55,21 +55,50 @@ export function composeRsvpNote(reply: CalendarReply): string { } /** - * Whether this response should surface the event thread as unread. + * Whether an attendee response warrants a note on the event thread. * - * A decline or a tentative response changes whether the meeting works and - * deserves attention. An acceptance tells the organizer nothing the event's - * guest list does not already show, so it must neither raise unread nor clear - * it — returning false here omits the flag entirely, which the runtime treats - * as "leave read state alone" (NOT as "mark read"). + * A bare acceptance repeats what the event's guest list already shows, so it + * earns no note. That is also the only way to keep it from raising unread: + * attaching a note surfaces the thread as unread for every recipient except + * the note's author, and no field a connector passes to `saveNote` can + * suppress that. Writing nothing is the guarantee. * - * The initial backfill never marks unread: folding a year of historical - * responses must not resurface old events. + * Everything else is genuinely new information and gets a note: + * a decline or a tentative changes whether the meeting works; an acceptance + * carrying a personal comment is a message from a person; and an acceptance + * that reverses an earlier decline or tentative is a real change of state, + * which `hadPriorNonAccept` reports from connector-local storage. */ -export function shouldMarkUnread( +export function shouldEmitRsvpNote( reply: CalendarReply, - initialSync: boolean + hadPriorNonAccept: boolean ): boolean { - if (initialSync) return false; - return reply.partstat === "DECLINED" || reply.partstat === "TENTATIVE"; + if (reply.partstat !== "ACCEPTED") return true; + if (reply.comment) return true; + return hadPriorNonAccept; +} + +/** + * Storage key holding an outstanding decline/tentative for one attendee on one + * event. Written when such a response is folded, cleared when that attendee + * later accepts — so the store only ever holds unresolved non-acceptances. + * + * Not read from `schedule_contact`: the calendar product's own attendee sync + * writes that same field from the event roster, so by the time an RSVP email is + * processed it may already read as accepted and the prior decline is gone. This + * key records what this connector last folded, which is the actual question. + * + * Scoped by `occurrence` as well as `uid`: a reply to one occurrence of a + * recurring event carries the same series `uid` as every other occurrence, + * distinguished only by `RECURRENCE-ID`. Without the occurrence in the key, a + * decline on one occurrence would be read as an outstanding non-acceptance for + * an unrelated occurrence's later reply. `null` (a series-wide response) maps + * to the literal `"series"` segment. + */ +export function priorRsvpKey( + uid: string, + attendeeEmail: string, + occurrence: Date | null +): string { + return `rsvp:${uid}:${occurrence ? occurrence.toISOString() : "series"}:${attendeeEmail.toLowerCase()}`; } diff --git a/connectors/google/src/mail/sync.test.ts b/connectors/google/src/mail/sync.test.ts index de1a38f3..2ce07078 100644 --- a/connectors/google/src/mail/sync.test.ts +++ b/connectors/google/src/mail/sync.test.ts @@ -21,6 +21,7 @@ import { REACTION_SEND_DELAY_MS, sendReactionEmailFn, } from "./sync"; +import { priorRsvpKey } from "./rsvp-note"; /** Decode the base64url raw message the Gmail send API would receive. */ function decodeRawMessage(b64url: string): string { @@ -935,7 +936,7 @@ describe("processEmailThreadsFn — calendar-thread bundling", () => { /** ICS body for one attendee response. */ function replyIcs( partstat: "DECLINED" | "ACCEPTED" | "TENTATIVE", - opts: { uid?: string; comment?: string } = {} + opts: { uid?: string; comment?: string; recurrenceId?: string } = {} ): string { const lines = [ "BEGIN:VCALENDAR", @@ -945,6 +946,7 @@ function replyIcs( `ATTENDEE;PARTSTAT=${partstat};CN=Beth Round:mailto:beth@example.test`, ]; if (opts.comment) lines.push(`COMMENT:${opts.comment}`); + if (opts.recurrenceId) lines.push(`RECURRENCE-ID:${opts.recurrenceId}`); lines.push("END:VEVENT", "END:VCALENDAR"); return lines.join("\r\n"); } @@ -1068,7 +1070,7 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", () ); }); - it("omits unread entirely for an acceptance", async () => { + it("writes no note at all for a bare acceptance, and saves no email link", async () => { const { host } = makeHost(); const { notes, links } = captureSaves(host); @@ -1079,24 +1081,121 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", () "INBOX" ); - expect(notes[0].content).toBe("Beth Round accepted."); - // Not `unread: false` — that would CLEAR unread the event already had. - expect(notes[0]).not.toHaveProperty("unread"); + // The guest list already shows the acceptance. Writing a note is the only + // thing that could mark the organiser's event thread unread, so we write none. + expect(notes).toHaveLength(0); expect(links).toHaveLength(0); }); - it("omits unread for every response during the initial backfill", async () => { + it("writes a note for an acceptance carrying a personal comment", async () => { const { host } = makeHost(); const { notes } = captureSaves(host); await processEmailThreadsFn( host, - [rsvpThread("rsvp-backfill", replyIcs("DECLINED"))], + [ + rsvpThread( + "rsvp-accepted-comment", + replyIcs("ACCEPTED", { comment: "Sounds good, I'll bring the deck" }) + ), + ], + false, + "INBOX" + ); + + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ + content: "Beth Round accepted.\n\n> Sounds good, I'll bring the deck", + unread: true, + }); + }); + + it("writes a note when an acceptance reverses an earlier decline", async () => { + const { host, store } = makeHost(); + const { notes } = captureSaves(host); + + await processEmailThreadsFn( + host, + [rsvpThread("rsvp-declined", replyIcs("DECLINED"))], + false, + "INBOX" + ); + const key = priorRsvpKey("uid-rsvp@google.com", "beth@example.test", null); + expect(store.get(key)).toBe("DECLINED"); + + await processEmailThreadsFn( + host, + [rsvpThread("rsvp-accepted", replyIcs("ACCEPTED"))], + false, + "INBOX" + ); + + // Two notes: the decline, then the reversal. + expect(notes).toHaveLength(2); + expect(notes[1]).toMatchObject({ content: "Beth Round accepted." }); + // The outstanding non-acceptance is resolved, so the key is gone. + expect(store.has(key)).toBe(false); + }); + + it("does not let a decline on one occurrence suppress an acceptance on another", async () => { + const { host, store } = makeHost(); + const { notes } = captureSaves(host); + + // Beth declines the Aug 4 occurrence of a recurring standup. + await processEmailThreadsFn( + host, + [ + rsvpThread( + "rsvp-recurring-declined", + replyIcs("DECLINED", { recurrenceId: "20260804T140000Z" }) + ), + ], + false, + "INBOX" + ); + expect(notes).toHaveLength(1); + const aug4Key = priorRsvpKey( + "uid-rsvp@google.com", + "beth@example.test", + new Date("2026-08-04T14:00:00Z") + ); + expect(store.get(aug4Key)).toBe("DECLINED"); + + // Two weeks later she bare-accepts a different occurrence of the same + // series (same UID, different RECURRENCE-ID). The Aug 4 decline must not + // be read as an outstanding non-acceptance for the Aug 18 occurrence. + await processEmailThreadsFn( + host, + [ + rsvpThread( + "rsvp-recurring-accepted", + replyIcs("ACCEPTED", { recurrenceId: "20260818T140000Z" }) + ), + ], + false, + "INBOX" + ); + + // No second note: the bare acceptance on the Aug 18 occurrence stays + // suppressed, and the Aug 4 decline's key is untouched. + expect(notes).toHaveLength(1); + expect(store.get(aug4Key)).toBe("DECLINED"); + }); + + it("marks a folded response read during the initial backfill", async () => { + const { host } = makeHost(); + const { notes } = captureSaves(host); + + await processEmailThreadsFn( + host, + [rsvpThread("rsvp-declined", replyIcs("DECLINED"))], true, "INBOX" ); - expect(notes[0]).not.toHaveProperty("unread"); + // Explicit false, not an omitted flag: a note already surfaces as unread + // by the time this field is read, so only an explicit false overrides it. + expect(notes[0]).toMatchObject({ unread: false }); }); it("keeps ordinary correspondence in its own email thread", async () => { @@ -1191,9 +1290,17 @@ describe("drainPendingRsvpsFn — retract once the event arrives", () => { function seedPending( host: GmailSyncHost, store: Map, - opts: { firstSeen?: string } = {} + opts: { + firstSeen?: string; + partstat?: "DECLINED" | "ACCEPTED" | "TENTATIVE"; + } = {} ) { - const gmailThread = rsvpThread("rsvp-late", replyIcs("ACCEPTED")); + // Declined by default: a bare acceptance now writes no note, so it cannot + // exercise the fold-and-retract path these tests cover. + const gmailThread = rsvpThread( + "rsvp-late", + replyIcs(opts.partstat ?? "DECLINED") + ); store.set("pending-rsvp:rsvp-late", { threadId: "rsvp-late", channelId: "INBOX", @@ -1264,7 +1371,9 @@ describe("drainPendingRsvpsFn — retract once the event arrives", () => { await drainPendingRsvpsFn(host); - expect(notes[0].unread).toBeUndefined(); + // Explicit false, not an omitted flag: omitting leaves the scoped-note + // trigger's unread standing, same convention as the live fold path. + expect(notes[0]).toMatchObject({ unread: false }); }); it("keeps the entry and archives nothing while the event is still missing", async () => { @@ -1278,6 +1387,21 @@ describe("drainPendingRsvpsFn — retract once the event arrives", () => { expect(store.has("pending-rsvp:rsvp-late")).toBe(true); }); + it("writes no note when the deferred response was a bare acceptance", async () => { + const { host, store } = makeHost(); + const { notes } = captureSaves(host, { noteId: "N" }); + seedPending(host, store, { partstat: "ACCEPTED" }); + + await drainPendingRsvpsFn(host); + + expect(notes).toHaveLength(0); + // Still retracted: nothing was left for the standalone email thread to show. + expect(host.tools.integrations.archiveLinks).toHaveBeenCalledWith({ + meta: { threadId: "rsvp-late" }, + }); + expect(store.has("pending-rsvp:rsvp-late")).toBe(false); + }); + it("gives up on an entry older than the retry window", async () => { const { host, store } = makeHost(); captureSaves(host, { noteId: "N" }); diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index cee65cd7..ad40e140 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -39,6 +39,7 @@ import type { WebhookRequest } from "@plotday/twister/tools/network"; import { type AttachmentData, + type CalendarReply, GmailApi, GmailApiError, type GmailMessage, @@ -70,7 +71,7 @@ import { type ClassifiedSendError, classifySendError, } from "./gmail-send-errors"; -import { composeRsvpNote, shouldMarkUnread } from "./rsvp-note"; +import { composeRsvpNote, priorRsvpKey, shouldEmitRsvpNote } from "./rsvp-note"; // --------------------------------------------------------------------------- // Persisted state shapes (shared with the connector) @@ -1573,6 +1574,20 @@ export async function drainPendingRsvpsFn(host: GmailSyncHost): Promise { let allFolded = true; for (const reply of replies) { + const priorKey = priorRsvpKey(reply.uid, reply.attendeeEmail, reply.occurrence); + // Only a bare acceptance consults prior state; every other response + // emits regardless, so skip the store round-trip. Each tools.* call + // spends the execution's request budget, and a backfill folds many + // responses at once. + const needsPriorState = reply.partstat === "ACCEPTED" && !reply.comment; + const hadPriorNonAccept = needsPriorState + ? Boolean(await host.get(priorKey)) + : false; + + // Same rule as the live path: a bare acceptance earns no note. It + // counts as folded so the retry still retracts the email thread. + if (!shouldEmitRsvpNote(reply, hadPriorNonAccept)) continue; + const noteId = await host.tools.integrations.saveNote({ thread: { source: `icaluid:${reply.uid}` }, key: reply.messageId, @@ -1583,11 +1598,13 @@ export async function drainPendingRsvpsFn(host: GmailSyncHost): Promise { email: reply.attendeeEmail, ...(reply.attendeeName ? { name: reply.attendeeName } : {}), }, - ...(shouldMarkUnread(reply, pending.initialSync) - ? { unread: true } - : {}), + unread: !pending.initialSync, }); - if (!noteId) allFolded = false; + if (!noteId) { + allFolded = false; + continue; + } + await recordRsvpOutcome(host, priorKey, reply.partstat); } // Retract only once every response reached the event, so a partially // folded conversation is never left with nowhere to read the rest. @@ -1623,6 +1640,23 @@ async function resolveBatchIcsFn( return resolveIcsByMessage(api, messages); } +/** + * Remember an outstanding decline/tentative so a later acceptance is recognised + * as a reversal, and forget it once that acceptance arrives. Keeps the store + * holding only unresolved non-acceptances. + */ +async function recordRsvpOutcome( + host: GmailSyncHost, + key: string, + partstat: CalendarReply["partstat"] +): Promise { + if (partstat === "ACCEPTED") { + await host.clear(key); + return; + } + await host.set(key, partstat); +} + /** * Persists one transformed Gmail thread: sent-note dedup, unread-state * mirroring, facet computation, the `saveLink` round-trip, and star↔to-do @@ -1669,6 +1703,26 @@ async function saveTransformedThread( const replies = extractCalendarReplies(thread.messages ?? [], icsByMessage); if (replies.length > 0) { for (const reply of replies) { + const priorKey = priorRsvpKey(reply.uid, reply.attendeeEmail, reply.occurrence); + // Only a bare acceptance consults prior state; every other response + // emits regardless, so skip the store round-trip. Each tools.* call + // spends the execution's request budget, and a backfill folds many + // responses at once. + const needsPriorState = reply.partstat === "ACCEPTED" && !reply.comment; + const hadPriorNonAccept = needsPriorState + ? Boolean(await host.get(priorKey)) + : false; + + // A bare acceptance says nothing the event's guest list does not + // already show. Drop the message rather than writing a note: a note + // is the only thing that could mark the organiser's thread unread, + // and marking it folded here keeps the responses-only conversation + // from becoming an email thread of its own. + if (!shouldEmitRsvpNote(reply, hadPriorNonAccept)) { + foldedMessageIds.add(reply.messageId); + continue; + } + // A miss means the calendar event has not synced yet (saveNote returns // null when no thread carries `icaluid:`). Leave the note in place // so the response still lands somewhere rather than vanishing. Expected, @@ -1683,9 +1737,16 @@ async function saveTransformedThread( email: reply.attendeeEmail, ...(reply.attendeeName ? { name: reply.attendeeName } : {}), }, - ...(shouldMarkUnread(reply, initialSync) ? { unread: true } : {}), + // Explicit on both paths. An omitted flag does NOT mean "leave read + // state alone": attaching a note already surfaces the thread as + // unread for every recipient except its author, so only an explicit + // false overrides it. + unread: !initialSync, }); - if (noteId) foldedMessageIds.add(reply.messageId); + if (noteId) { + foldedMessageIds.add(reply.messageId); + await recordRsvpOutcome(host, priorKey, reply.partstat); + } } // Any response that missed its event is worth retrying: the calendar