From b92904af025797c81721e81878ebbd52e679e707 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 1 Aug 2026 22:00:03 -0400 Subject: [PATCH 1/5] Decide whether an RSVP warrants a note, not whether it marks unread --- connectors/google/src/mail/rsvp-note.test.ts | 48 ++++++++++++++++---- connectors/google/src/mail/rsvp-note.ts | 42 ++++++++++++----- 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/connectors/google/src/mail/rsvp-note.test.ts b/connectors/google/src/mail/rsvp-note.test.ts index f95df8ee..5e6fefc0 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,46 @@ 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")).toBe( + "rsvp:uid-1@google.com: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")).toBe( + priorRsvpKey("uid-1@google.com", "beth@example.test") + ); }); }); diff --git a/connectors/google/src/mail/rsvp-note.ts b/connectors/google/src/mail/rsvp-note.ts index 00708193..80ff6841 100644 --- a/connectors/google/src/mail/rsvp-note.ts +++ b/connectors/google/src/mail/rsvp-note.ts @@ -55,21 +55,39 @@ 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: + * the platform's scoped-note trigger marks every non-author unread the moment + * a note is inserted, and no value a connector passes to `saveNote` prevents + * it. 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. + */ +export function priorRsvpKey(uid: string, attendeeEmail: string): string { + return `rsvp:${uid}:${attendeeEmail.toLowerCase()}`; } From d26e23ea10faddb6977cf63cd0235fd8d1e5c121 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 1 Aug 2026 22:06:22 -0400 Subject: [PATCH 2/5] Stop writing a note for a bare RSVP acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare "accepted" response repeats what the event's guest list already shows, so folding it onto the event thread as a note was pure noise — and worse, any note on the thread flips it unread for the organiser even when the connector didn't ask for that. The fix is to write no note at all for a bare acceptance: nothing new is being said. An acceptance still gets a note when it carries a personal comment, or when it reverses an attendee's earlier decline/tentative — both are real information. A suppressed bare acceptance is still folded away so a responses-only conversation doesn't surface as its own email thread. --- connectors/google/src/mail/sync.test.ts | 65 ++++++++++++++++++++++--- connectors/google/src/mail/sync.ts | 43 ++++++++++++++-- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/connectors/google/src/mail/sync.test.ts b/connectors/google/src/mail/sync.test.ts index de1a38f3..72e75d1b 100644 --- a/connectors/google/src/mail/sync.test.ts +++ b/connectors/google/src/mail/sync.test.ts @@ -1068,7 +1068,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 +1079,75 @@ 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" + ); + expect(store.get("rsvp:uid-rsvp@google.com:beth@example.test")).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("rsvp:uid-rsvp@google.com:beth@example.test")).toBe(false); + }); + + 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: omitting leaves the scoped-note + // trigger's unread standing, which is what broke the original guard. + expect(notes[0]).toMatchObject({ unread: false }); }); it("keeps ordinary correspondence in its own email thread", async () => { diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index cee65cd7..35de3824 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) @@ -1623,6 +1624,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 +1687,19 @@ 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); + const hadPriorNonAccept = Boolean(await host.get(priorKey)); + + // 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 +1714,15 @@ 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": the scoped-note trigger has already marked every + // non-author unread by the time the runtime reads this field. + 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 From 4b4544016d72f9633356f765d8a31bb39b1667f9 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 1 Aug 2026 22:13:20 -0400 Subject: [PATCH 3/5] Apply the RSVP note rule on the deferred-retry path too --- connectors/google/src/mail/sync.test.ts | 31 ++++++++++++++++++++++--- connectors/google/src/mail/sync.ts | 17 ++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/connectors/google/src/mail/sync.test.ts b/connectors/google/src/mail/sync.test.ts index 72e75d1b..00a0ba1c 100644 --- a/connectors/google/src/mail/sync.test.ts +++ b/connectors/google/src/mail/sync.test.ts @@ -1242,9 +1242,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", @@ -1315,7 +1323,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 () => { @@ -1329,6 +1339,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 35de3824..8a45a454 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -1574,6 +1574,13 @@ export async function drainPendingRsvpsFn(host: GmailSyncHost): Promise { let allFolded = true; for (const reply of replies) { + const priorKey = priorRsvpKey(reply.uid, reply.attendeeEmail); + const hadPriorNonAccept = Boolean(await host.get(priorKey)); + + // 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, @@ -1584,11 +1591,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. From 2da5ee168d8dcac609ffccc7b41c716adb963c4d Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 1 Aug 2026 22:31:25 -0400 Subject: [PATCH 4/5] Scope the RSVP dedup key to the occurrence, not just the series Replies to a single occurrence of a recurring event share the same calendar UID as every other occurrence in the series, distinguished only by RECURRENCE-ID. The bare-acceptance suppression rule tracked outstanding declines/tentatives per UID + attendee only, so a decline on one occurrence could suppress the note for a bare acceptance on an unrelated occurrence of the same series (and vice versa, losing a genuine reversal). The lookup key now includes the occurrence. Also makes the prior-state lookup lazy: it's only ever consulted for a bare acceptance, so declines and tentatives no longer pay a wasted storage round-trip. --- connectors/google/src/mail/rsvp-note.test.ts | 40 ++++++++++++-- connectors/google/src/mail/rsvp-note.ts | 21 +++++-- connectors/google/src/mail/sync.test.ts | 58 ++++++++++++++++++-- connectors/google/src/mail/sync.ts | 22 ++++++-- 4 files changed, 123 insertions(+), 18 deletions(-) diff --git a/connectors/google/src/mail/rsvp-note.test.ts b/connectors/google/src/mail/rsvp-note.test.ts index 5e6fefc0..a243e63d 100644 --- a/connectors/google/src/mail/rsvp-note.test.ts +++ b/connectors/google/src/mail/rsvp-note.test.ts @@ -112,14 +112,46 @@ describe("shouldEmitRsvpNote", () => { describe("priorRsvpKey", () => { it("scopes the key to the event and the attendee", () => { - expect(priorRsvpKey("uid-1@google.com", "beth@example.test")).toBe( - "rsvp:uid-1@google.com:beth@example.test" + 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")).toBe( - priorRsvpKey("uid-1@google.com", "beth@example.test") + 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 80ff6841..7e4bd026 100644 --- a/connectors/google/src/mail/rsvp-note.ts +++ b/connectors/google/src/mail/rsvp-note.ts @@ -59,9 +59,9 @@ export function composeRsvpNote(reply: CalendarReply): string { * * 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: - * the platform's scoped-note trigger marks every non-author unread the moment - * a note is inserted, and no value a connector passes to `saveNote` prevents - * it. Writing nothing is the guarantee. + * 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. * * Everything else is genuinely new information and gets a note: * a decline or a tentative changes whether the meeting works; an acceptance @@ -87,7 +87,18 @@ export function shouldEmitRsvpNote( * 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): string { - return `rsvp:${uid}:${attendeeEmail.toLowerCase()}`; +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 00a0ba1c..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"); } @@ -1118,7 +1120,8 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", () false, "INBOX" ); - expect(store.get("rsvp:uid-rsvp@google.com:beth@example.test")).toBe("DECLINED"); + const key = priorRsvpKey("uid-rsvp@google.com", "beth@example.test", null); + expect(store.get(key)).toBe("DECLINED"); await processEmailThreadsFn( host, @@ -1131,7 +1134,52 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", () 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("rsvp:uid-rsvp@google.com:beth@example.test")).toBe(false); + 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 () => { @@ -1145,8 +1193,8 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", () "INBOX" ); - // Explicit false, not an omitted flag: omitting leaves the scoped-note - // trigger's unread standing, which is what broke the original guard. + // 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 }); }); diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index 8a45a454..b20e765c 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -1574,8 +1574,15 @@ export async function drainPendingRsvpsFn(host: GmailSyncHost): Promise { let allFolded = true; for (const reply of replies) { - const priorKey = priorRsvpKey(reply.uid, reply.attendeeEmail); - const hadPriorNonAccept = Boolean(await host.get(priorKey)); + 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. @@ -1696,8 +1703,15 @@ 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); - const hadPriorNonAccept = Boolean(await host.get(priorKey)); + 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 From c9b5373af97335c09067fbd2fbb49c13f663175d Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 1 Aug 2026 23:11:24 -0400 Subject: [PATCH 5/5] Describe the unread contract in connector-observable terms The comment explained the behaviour in terms of internal storage mechanics rather than what a connector author can observe and rely on. --- connectors/google/src/mail/sync.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index b20e765c..ad40e140 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -1738,8 +1738,9 @@ async function saveTransformedThread( ...(reply.attendeeName ? { name: reply.attendeeName } : {}), }, // Explicit on both paths. An omitted flag does NOT mean "leave read - // state alone": the scoped-note trigger has already marked every - // non-author unread by the time the runtime reads this field. + // 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) {