From 2bc3240071a980caafcc43c55f5f9782adc7dda7 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 10:04:40 -0400 Subject: [PATCH 01/14] Move the RSVP fold rule into a shared library Google Calendar and Outlook Calendar both need to fold an attendee's RSVP onto the event's thread using the same rule: a bare acceptance is suppressed (it repeats what the guest list already shows and would otherwise mark the thread unread for no new information), while a decline, a tentative, a commented acceptance, or an acceptance that reverses an earlier non-acceptance all earn a note. Since connectors can't import from one another, this rule moves out of the Gmail connector and into a new shared `@plotday/rsvp-fold` package under `libs/`, so both calendar connectors can share one implementation and one set of tests. The Gmail connector now consumes it via `@plotday/rsvp-fold` instead of a local module; its own behavior and tests are unchanged. --- connectors/google/package.json | 1 + connectors/google/src/mail/sync.test.ts | 2 +- connectors/google/src/mail/sync.ts | 2 +- libs/rsvp-fold/README.md | 28 ++++++++++++ libs/rsvp-fold/package.json | 44 +++++++++++++++++++ libs/rsvp-fold/src/index.ts | 6 +++ .../rsvp-fold/src}/rsvp-note.test.ts | 7 +-- .../mail => libs/rsvp-fold/src}/rsvp-note.ts | 26 +++++++++-- libs/rsvp-fold/tsconfig.json | 8 ++++ pnpm-lock.yaml | 15 +++++++ 10 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 libs/rsvp-fold/README.md create mode 100644 libs/rsvp-fold/package.json create mode 100644 libs/rsvp-fold/src/index.ts rename {connectors/google/src/mail => libs/rsvp-fold/src}/rsvp-note.test.ts (95%) rename {connectors/google/src/mail => libs/rsvp-fold/src}/rsvp-note.ts (82%) create mode 100644 libs/rsvp-fold/tsconfig.json diff --git a/connectors/google/package.json b/connectors/google/package.json index fc8016a2..748711af 100644 --- a/connectors/google/package.json +++ b/connectors/google/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@plotday/google-contacts": "workspace:^", + "@plotday/rsvp-fold": "workspace:^", "@plotday/twister": "workspace:^" }, "devDependencies": { diff --git a/connectors/google/src/mail/sync.test.ts b/connectors/google/src/mail/sync.test.ts index e3b5ae78..1fd780b9 100644 --- a/connectors/google/src/mail/sync.test.ts +++ b/connectors/google/src/mail/sync.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { CreateLinkDraft, NewLinkWithNotes, Uuid } from "@plotday/twister"; +import { priorRsvpKey } from "@plotday/rsvp-fold"; import { GmailApi, @@ -20,7 +21,6 @@ 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 { diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index 4aef73f8..b9448c2e 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -17,6 +17,7 @@ * returns a descriptor and lets the caller own the scheduling. */ import { enrichLinkContactsFromGoogle } from "@plotday/google-contacts"; +import { composeRsvpNote, priorRsvpKey, shouldEmitRsvpNote } from "@plotday/rsvp-fold"; import { baseEmail, canonicalizeEmail, @@ -70,7 +71,6 @@ import { type ClassifiedSendError, classifySendError, } from "./gmail-send-errors"; -import { composeRsvpNote, priorRsvpKey, shouldEmitRsvpNote } from "./rsvp-note"; // --------------------------------------------------------------------------- // Persisted state shapes (shared with the connector) diff --git a/libs/rsvp-fold/README.md b/libs/rsvp-fold/README.md new file mode 100644 index 00000000..8e509d3a --- /dev/null +++ b/libs/rsvp-fold/README.md @@ -0,0 +1,28 @@ +# RSVP Fold + +Shared rule for folding a calendar attendee's response (accept / decline / +tentative) onto the event's thread as a note. + +## What it does + +- `composeRsvpNote(reply)` — formats the one-line note (plus the responder's + personal comment, when they left one) that a connector attaches to the + event thread. +- `shouldEmitRsvpNote(reply, hadPriorNonAccept)` — decides whether a response + earns a note at all. A bare acceptance repeats what the event's guest list + already shows, so it is deliberately suppressed — attaching any note marks + the thread unread for everyone but the note's author, and suppression is + the only way to avoid that for a response carrying no new information. +- `priorRsvpKey(uid, attendeeEmail, occurrence)` — the storage key a connector + uses to remember an outstanding decline/tentative for one attendee on one + event (or occurrence, for a recurring series), so a later bare acceptance + can be recognised as a real change of state. + +Connectors that sync calendar invitations (Google Calendar, Outlook) supply +their own attendee response as an `RsvpReply` and share this one rule, so the +same event produces the same note and the same unread behaviour regardless of +which calendar it came from. + +## License + +MIT © Plot Technologies Inc. diff --git a/libs/rsvp-fold/package.json b/libs/rsvp-fold/package.json new file mode 100644 index 00000000..af3ee84d --- /dev/null +++ b/libs/rsvp-fold/package.json @@ -0,0 +1,44 @@ +{ + "name": "@plotday/rsvp-fold", + "author": "Plot (https://plot.day)", + "license": "MIT", + "version": "0.1.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "@plotday/connector": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "private": true, + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "lint": "plot lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@plotday/twister": "workspace:^", + "typescript": "^5.9.3", + "vitest": "^2.1.8" + }, + "repository": { + "type": "git", + "url": "https://github.com/plotday/plot.git", + "directory": "libs/rsvp-fold" + }, + "homepage": "https://plot.day", + "bugs": { + "url": "https://github.com/plotday/plot/issues" + }, + "keywords": [ + "plot", + "connector", + "calendar", + "rsvp" + ] +} diff --git a/libs/rsvp-fold/src/index.ts b/libs/rsvp-fold/src/index.ts new file mode 100644 index 00000000..484c9cf1 --- /dev/null +++ b/libs/rsvp-fold/src/index.ts @@ -0,0 +1,6 @@ +export { + composeRsvpNote, + shouldEmitRsvpNote, + priorRsvpKey, + type RsvpReply, +} from "./rsvp-note"; diff --git a/connectors/google/src/mail/rsvp-note.test.ts b/libs/rsvp-fold/src/rsvp-note.test.ts similarity index 95% rename from connectors/google/src/mail/rsvp-note.test.ts rename to libs/rsvp-fold/src/rsvp-note.test.ts index a243e63d..e28905b4 100644 --- a/connectors/google/src/mail/rsvp-note.test.ts +++ b/libs/rsvp-fold/src/rsvp-note.test.ts @@ -1,19 +1,16 @@ import { describe, expect, it } from "vitest"; -import type { CalendarReply } from "./gmail-api"; import { composeRsvpNote, shouldEmitRsvpNote, priorRsvpKey } from "./rsvp-note"; +import type { RsvpReply } from "./rsvp-note"; -function reply(overrides: Partial = {}): CalendarReply { +function reply(overrides: Partial = {}): RsvpReply { return { - messageId: "m1", - uid: "uid-1@google.com", partstat: "DECLINED", attendeeName: "Beth Round", attendeeEmail: "beth@example.test", occurrence: null, allDay: false, comment: null, - sourceCreatedAt: new Date("2026-07-24T20:50:24Z"), ...overrides, }; } diff --git a/connectors/google/src/mail/rsvp-note.ts b/libs/rsvp-fold/src/rsvp-note.ts similarity index 82% rename from connectors/google/src/mail/rsvp-note.ts rename to libs/rsvp-fold/src/rsvp-note.ts index 7e4bd026..502a8fde 100644 --- a/connectors/google/src/mail/rsvp-note.ts +++ b/libs/rsvp-fold/src/rsvp-note.ts @@ -6,9 +6,27 @@ * which the event thread already shows. Only the response itself and the * responder's personal note are new, so that is all these notes carry. */ -import type { CalendarReply } from "./gmail-api"; -const VERBS: Record = { +/** + * One attendee's response to a calendar invitation, in the shape the fold rule + * needs. Providers supply this from whatever they have — an iCalendar part, a + * provider-specific message type — so this library stays independent of any + * one of them. + */ +export type RsvpReply = { + partstat: "ACCEPTED" | "DECLINED" | "TENTATIVE"; + /** Display name, when the provider gives one. */ + attendeeName: string | null; + attendeeEmail: string; + /** The instance responded to; null when the response covers the whole series. */ + occurrence: Date | null; + /** The occurrence was all-day — affects date formatting only. */ + allDay: boolean; + /** The responder's personal note, when they wrote one. */ + comment: string | null; +}; + +const VERBS: Record = { DECLINED: "declined", ACCEPTED: "accepted", TENTATIVE: "tentatively accepted", @@ -42,7 +60,7 @@ function blockquote(text: string): string { * response was to a single instance of a series, and appends the responder's * personal note as a blockquote when they wrote one. */ -export function composeRsvpNote(reply: CalendarReply): string { +export function composeRsvpNote(reply: RsvpReply): string { const who = reply.attendeeName ?? reply.attendeeEmail; const verb = VERBS[reply.partstat]; const where = reply.occurrence @@ -70,7 +88,7 @@ export function composeRsvpNote(reply: CalendarReply): string { * which `hadPriorNonAccept` reports from connector-local storage. */ export function shouldEmitRsvpNote( - reply: CalendarReply, + reply: RsvpReply, hadPriorNonAccept: boolean ): boolean { if (reply.partstat !== "ACCEPTED") return true; diff --git a/libs/rsvp-fold/tsconfig.json b/libs/rsvp-fold/tsconfig.json new file mode 100644 index 00000000..b98a1162 --- /dev/null +++ b/libs/rsvp-fold/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@plotday/twister/tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bfacb6c..bdbd4ddd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@plotday/google-contacts': specifier: workspace:^ version: link:../../libs/google-contacts + '@plotday/rsvp-fold': + specifier: workspace:^ + version: link:../../libs/rsvp-fold '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -302,6 +305,18 @@ importers: specifier: ^5.9.3 version: 5.9.3 + libs/rsvp-fold: + devDependencies: + '@plotday/twister': + specifier: workspace:^ + version: link:../../twister + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@25.0.3) + scripts: devDependencies: '@plotday/twister': From f44566ed66c6c3568090d8d700017785da04fc8a Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 10:17:07 -0400 Subject: [PATCH 02/14] Classify Outlook meeting responses and carry their occurrence --- connectors/outlook/package.json | 1 + .../outlook/src/mail/graph-mail-api.test.ts | 56 +++++++++++++++++-- connectors/outlook/src/mail/graph-mail-api.ts | 55 +++++++++++++++--- pnpm-lock.yaml | 3 + 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/connectors/outlook/package.json b/connectors/outlook/package.json index de914d0f..8d489b1c 100644 --- a/connectors/outlook/package.json +++ b/connectors/outlook/package.json @@ -30,6 +30,7 @@ "test:watch": "vitest" }, "dependencies": { + "@plotday/rsvp-fold": "workspace:^", "@plotday/twister": "workspace:^" }, "devDependencies": { diff --git a/connectors/outlook/src/mail/graph-mail-api.test.ts b/connectors/outlook/src/mail/graph-mail-api.test.ts index 289891b7..db5f98fd 100644 --- a/connectors/outlook/src/mail/graph-mail-api.test.ts +++ b/connectors/outlook/src/mail/graph-mail-api.test.ts @@ -261,7 +261,7 @@ describe("GraphMailApi queries", () => { "microsoft.graph.eventMessage/meetingMessageType" ); expect(calls[0]?.$expand).toBe( - "microsoft.graph.eventMessage/event($select=iCalUId)" + "microsoft.graph.eventMessage/event($select=iCalUId,originalStart,type)" ); }); @@ -281,7 +281,7 @@ describe("GraphMailApi queries", () => { "microsoft.graph.eventMessage/meetingMessageType" ); expect(calls[0]?.$expand).toBe( - "microsoft.graph.eventMessage/event($select=iCalUId)" + "microsoft.graph.eventMessage/event($select=iCalUId,originalStart,type)" ); }); }); @@ -346,13 +346,59 @@ describe("classifyOutlookCalendar", () => { ).toEqual({ uid: "uid-1", kind: "cancel" }); }); - it("skips RSVP responses (accept/decline/tentative)", () => { + it("classifies an acceptance, a decline and a tentative as rsvp", () => { + for (const [type, partstat] of [ + ["meetingAccepted", "ACCEPTED"], + ["meetingDeclined", "DECLINED"], + ["meetingTentativelyAccepted", "TENTATIVE"], + ] as const) { + expect( + classifyOutlookCalendar( + [msg({ meetingMessageType: type, event: { iCalUId: "u" } })], + null + ) + ).toMatchObject({ uid: "u", kind: "rsvp", partstat }); + } + }); + + it("carries the occurrence for a response to one instance of a series", () => { + const r = classifyOutlookCalendar( + [ + msg({ + meetingMessageType: "meetingAccepted", + event: { + iCalUId: "u", + type: "occurrence", + originalStart: "2026-08-04T14:00:00Z", + }, + }), + ], + null + ); + expect(r).toMatchObject({ kind: "rsvp", partstat: "ACCEPTED" }); + expect((r as { occurrence: Date }).occurrence).toEqual( + new Date("2026-08-04T14:00:00Z") + ); + }); + + it("leaves occurrence null for a response to the whole series", () => { + const r = classifyOutlookCalendar( + [msg({ meetingMessageType: "meetingAccepted", event: { iCalUId: "u", type: "singleInstance" } })], + null + ); + expect((r as { occurrence: Date | null }).occurrence).toBeNull(); + }); + + it("still prefers cancel and request over an rsvp in the same conversation", () => { expect( classifyOutlookCalendar( - [msg({ meetingMessageType: "meetingAccepted", event: { iCalUId: "u" } })], + [ + msg({ meetingMessageType: "meetingAccepted", event: { iCalUId: "u" } }), + msg({ meetingMessageType: "meetingCancelled", event: { iCalUId: "u" } }), + ], null ) - ).toBeNull(); + ).toMatchObject({ kind: "cancel" }); }); it("skips a qualifying meetingMessageType without an event.iCalUId", () => { diff --git a/connectors/outlook/src/mail/graph-mail-api.ts b/connectors/outlook/src/mail/graph-mail-api.ts index 54da0fea..ab460a79 100644 --- a/connectors/outlook/src/mail/graph-mail-api.ts +++ b/connectors/outlook/src/mail/graph-mail-api.ts @@ -7,6 +7,7 @@ import type { NewLinkWithNotes, } from "@plotday/twister/plot"; import { isNoReplySender } from "@plotday/twister/signals"; +import type { RsvpReply } from "@plotday/rsvp-fold"; import { stripQuotedReply } from "./email-parsing"; export type GraphRecipient = { @@ -43,7 +44,7 @@ export type GraphMessage = { internetMessageHeaders?: GraphHeader[]; "@odata.type"?: string; meetingMessageType?: string; - event?: { iCalUId?: string }; + event?: { iCalUId?: string; originalStart?: string; type?: string }; }; export type GraphMailFolder = { @@ -303,7 +304,11 @@ export class GraphMailApi { // 'event' on type 'microsoft.graph.message'". The OData type-cast // segment scopes the expand to items that are actually eventMessages; // plain messages just come back without an `event` field. - $expand: "microsoft.graph.eventMessage/event($select=iCalUId)", + // originalStart + type let classifyOutlookCalendar tell a response to + // one occurrence of a recurring series apart from a series-wide + // response — both share the same iCalUId. + $expand: + "microsoft.graph.eventMessage/event($select=iCalUId,originalStart,type)", }; if (args.since) { params.$filter = `receivedDateTime ge ${args.since.toISOString()}`; @@ -345,7 +350,11 @@ export class GraphMailApi { $filter: `conversationId eq ${odataQuote(conversationId)}`, $top: "100", $select: MESSAGE_SELECT_COLLECTION, - $expand: "microsoft.graph.eventMessage/event($select=iCalUId)", + // originalStart + type let classifyOutlookCalendar tell a response to + // one occurrence of a recurring series apart from a series-wide + // response — both share the same iCalUId. + $expand: + "microsoft.graph.eventMessage/event($select=iCalUId,originalStart,type)", }); for (let page = 0; page < 5; page++) { messages.push(...((data?.value as GraphMessage[] | undefined) ?? [])); @@ -650,25 +659,42 @@ export function sortConversation(messages: GraphMessage[]): GraphMessage[] { ); } +/** Graph's meeting-response type → the shared fold rule's `partstat`. */ +const RSVP_PARTSTAT: Record = { + meetingAccepted: "ACCEPTED", + meetingDeclined: "DECLINED", + meetingTentativelyAccepted: "TENTATIVE", +}; + /** * Classify an Outlook conversation's relationship to a calendar event for * bundling onto the event's Plot thread. Two signals: our own * `X-Plot-Event-UID` header on the parent's raw headers (a Plot-sent reply * chain — checked first, regardless of any message-derived signal), or a - * message's Graph meeting-message metadata (update/cancellation). + * message's Graph meeting-message metadata (update/cancellation/RSVP). * * Graph's `meetingMessageType` doesn't distinguish a brand-new invite from * an update/reschedule to an existing meeting the way Exchange Web * Services' `MeetingRequestType` (fullUpdate/informationalUpdate/ * newMeetingRequest) does — Graph has no equivalent property, so every * `meetingRequest` bundles onto its event's thread here, new invite or not. - * RSVP responses (accept/decline/tentative) fall through and are skipped, - * since they match neither branch below. + * Cancel and update are checked across the whole conversation before RSVP is + * considered at all, in a separate pass — so a conversation carrying both a + * response and a cancellation/update still classifies as the stronger kind + * regardless of which message comes first. RSVP responses + * (accept/decline/tentative) carry the occurrence they responded to (`null` + * for a whole-series response) so callers can key dedup state per-occurrence + * rather than per-series. */ export function classifyOutlookCalendar( messages: GraphMessage[], parentHeaders: GraphHeader[] | null -): { uid: string; kind: "reply" | "update" | "cancel" } | null { +): { + uid: string; + kind: "reply" | "update" | "cancel" | "rsvp"; + partstat?: RsvpReply["partstat"]; + occurrence?: Date | null; +} | null { const hdr = (parentHeaders ?? []).find( (h) => h.name.toLowerCase() === "x-plot-event-uid" ); @@ -679,6 +705,21 @@ export function classifyOutlookCalendar( if (m.meetingMessageType === "meetingCancelled") return { uid, kind: "cancel" }; if (m.meetingMessageType === "meetingRequest") return { uid, kind: "update" }; } + for (const m of messages) { + const uid = m.event?.iCalUId; + if (!uid) continue; + const partstat = m.meetingMessageType + ? RSVP_PARTSTAT[m.meetingMessageType] + : undefined; + if (partstat) { + const occurrence = + (m.event?.type === "occurrence" || m.event?.type === "exception") && + m.event?.originalStart + ? new Date(m.event.originalStart) + : null; + return { uid, kind: "rsvp", partstat, occurrence }; + } + } return null; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdbd4ddd..107e4995 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: connectors/outlook: dependencies: + '@plotday/rsvp-fold': + specifier: workspace:^ + version: link:../../libs/rsvp-fold '@plotday/twister': specifier: workspace:^ version: link:../../twister From 1e2e4843c080f0ec18df5d7a57fb7fdbb14aab9c Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 10:23:39 -0400 Subject: [PATCH 03/14] Validate originalStart before treating it as an occurrence An unparseable originalStart on an occurrence/exception event previously produced an Invalid Date, which crashes downstream at priorRsvpKey's occurrence.toISOString() call. Parse it the same way parseIcsDate already does for the Gmail connector: validate the parsed epoch and fall back to null (a series-scoped key) instead of an uncaught RangeError. --- .../outlook/src/mail/graph-mail-api.test.ts | 14 ++++++++++++++ connectors/outlook/src/mail/graph-mail-api.ts | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/connectors/outlook/src/mail/graph-mail-api.test.ts b/connectors/outlook/src/mail/graph-mail-api.test.ts index db5f98fd..c24f7c0a 100644 --- a/connectors/outlook/src/mail/graph-mail-api.test.ts +++ b/connectors/outlook/src/mail/graph-mail-api.test.ts @@ -389,6 +389,20 @@ describe("classifyOutlookCalendar", () => { expect((r as { occurrence: Date | null }).occurrence).toBeNull(); }); + it("degrades a malformed originalStart on an occurrence to occurrence: null instead of an Invalid Date", () => { + const r = classifyOutlookCalendar( + [ + msg({ + meetingMessageType: "meetingAccepted", + event: { iCalUId: "u", type: "occurrence", originalStart: "not-a-date" }, + }), + ], + null + ); + expect(r).toMatchObject({ kind: "rsvp", partstat: "ACCEPTED" }); + expect((r as { occurrence: Date | null }).occurrence).toBeNull(); + }); + it("still prefers cancel and request over an rsvp in the same conversation", () => { expect( classifyOutlookCalendar( diff --git a/connectors/outlook/src/mail/graph-mail-api.ts b/connectors/outlook/src/mail/graph-mail-api.ts index ab460a79..9cbc4dc3 100644 --- a/connectors/outlook/src/mail/graph-mail-api.ts +++ b/connectors/outlook/src/mail/graph-mail-api.ts @@ -666,6 +666,22 @@ const RSVP_PARTSTAT: Record = { meetingTentativelyAccepted: "TENTATIVE", }; +/** + * Parse Graph's `originalStart` (an ISO 8601 instant) into a `Date`, + * validating rather than trusting `new Date(string)` — a malformed or + * truncated value yields `Invalid Date`, not a thrown error, and that value + * would otherwise flow into `RsvpReply.occurrence` and reach + * `priorRsvpKey`'s unconditional `occurrence.toISOString()`, throwing + * `RangeError: Invalid time value` and crashing the sync pass. `null` here + * degrades to a series-scoped key instead — the same safe outcome as a + * genuine whole-series response (see `parseIcsDate` in the Gmail connector + * for the same pattern against ICS dates). + */ +function parseOriginalStart(value: string): Date | null { + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : new Date(ms); +} + /** * Classify an Outlook conversation's relationship to a calendar event for * bundling onto the event's Plot thread. Two signals: our own @@ -715,7 +731,7 @@ export function classifyOutlookCalendar( const occurrence = (m.event?.type === "occurrence" || m.event?.type === "exception") && m.event?.originalStart - ? new Date(m.event.originalStart) + ? parseOriginalStart(m.event.originalStart) : null; return { uid, kind: "rsvp", partstat, occurrence }; } From a972cbc42633f02b12bc6eaa2bd0577c3dbd5bcb Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 11:36:35 -0400 Subject: [PATCH 04/14] Share the iCalendar reply parser between connectors Both Google Calendar and Microsoft Exchange notify an organizer of an attendee's accept/decline/tentative response with the same `METHOD:REPLY` iCalendar shape, differing only in where the responder's personal note lives (a standard `COMMENT` property vs. an `X-RESPONSE-COMMENT` parameter on the `ATTENDEE` line). Moves that per-ICS parse into `@plotday/rsvp-fold` as `parseIcsReply`, so any calendar connector can read a reply without re-implementing the format, and Gmail now delegates to it instead of carrying its own copy. --- connectors/google/src/mail/gmail-api.ts | 133 ++++------------ libs/rsvp-fold/src/ics-reply.test.ts | 196 ++++++++++++++++++++++++ libs/rsvp-fold/src/ics-reply.ts | 158 +++++++++++++++++++ libs/rsvp-fold/src/index.ts | 2 + 4 files changed, 384 insertions(+), 105 deletions(-) create mode 100644 libs/rsvp-fold/src/ics-reply.test.ts create mode 100644 libs/rsvp-fold/src/ics-reply.ts diff --git a/connectors/google/src/mail/gmail-api.ts b/connectors/google/src/mail/gmail-api.ts index 0a865fe5..424593d5 100644 --- a/connectors/google/src/mail/gmail-api.ts +++ b/connectors/google/src/mail/gmail-api.ts @@ -10,6 +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 { parseIcsReply } from "@plotday/rsvp-fold"; export type GmailLabel = { @@ -770,9 +771,11 @@ function normalizeMessageId(raw: string | null): string | null { /** * Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and * match one property line: group 1 is its parameter section (leading `;` - * included, or `""` when there are none), group 2 is its value. Shared by - * `icsProp` (value only) and `icsPropLine` (params + value), so the - * unfolding rule and line regex exist exactly once. + * included, or `""` when there are none), group 2 is its value. Used by + * `icsProp` for the property lookups `classifyCalendarThread` still needs + * locally (`METHOD`, `UID`, `SEQUENCE`) — the params+value variant of this + * helper (`icsPropLine`) moved to `@plotday/rsvp-fold` along with the rest of + * the reply parse. */ function matchIcsLine(ics: string, name: string): RegExpMatchArray | null { const unfolded = ics.replace(/\r?\n[ \t]/g, ""); @@ -927,60 +930,6 @@ export type CalendarReply = { sourceCreatedAt: Date; }; -/** - * RFC 5545 text un-escaping: `\n`/`\N` → newline, `\,` `\;` `\\` → the - * literal character. Single-pass so an escaped backslash immediately - * 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. - */ -function unescapeIcsText(value: string): string { - return value.replace(/\\([nN,;\\])/g, (_, ch: string) => - ch === "n" || ch === "N" ? "\n" : ch - ); -} - -/** - * Read a property's raw line (parameters included) from an ICS body. Shares - * `icsProp`'s unfolding and line regex via `matchIcsLine`, but returns - * everything after the property name so parameters can be parsed. - */ -function icsPropLine(ics: string, name: string): string | null { - const m = matchIcsLine(ics, name); - return m ? `${m[1]}:${m[2]}` : null; -} - -/** - * Split an ICS property's parameter section into a map. Values may be quoted - * (`X-RESPONSE-COMMENT="a, b"`), and a quoted value may contain the `;` and - * `:` that otherwise delimit parameters — so scan rather than split. - */ -function parseIcsParams(paramSection: string): Record { - const params: Record = {}; - const re = /;([A-Za-z0-9-]+)=("([^"]*)"|[^;:]*)/g; - let m: RegExpExecArray | null; - while ((m = re.exec(paramSection)) !== null) { - params[m[1].toUpperCase()] = m[3] !== undefined ? m[3] : m[2]; - } - return params; -} - -/** - * Parse an ICS date-time into a UTC instant. Handles `20260804T140000Z` - * (UTC), `20260804T100000` (floating or TZID-qualified — read as UTC, since - * resolving a TZID needs a tz database the worker doesn't carry), and - * `20260804` (VALUE=DATE). - */ -function parseIcsDate(value: string): Date | null { - const m = value - .trim() - .match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/); - if (!m) return null; - const [, y, mo, d, h = "00", mi = "00", s = "00"] = m; - const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); - return Number.isNaN(ms) ? null : new Date(ms); -} - /** * Google's response-notification body opens with " has declined this * invitation with a note:" followed by the quoted comment, before the Meet / @@ -1008,9 +957,16 @@ function commentFromBody(message: GmailMessage): string | null { * Extract every attendee response carried by a Gmail conversation. * * A descriptor is produced for each message whose calendar body — read ahead - * of time by {@link resolveIcsByMessage} — carries `METHOD:REPLY`, a `UID`, an - * `ATTENDEE` with a decided `PARTSTAT`, and a resolvable attendee address. - * `NEEDS-ACTION` yields nothing — there is no response to report. + * of time by {@link resolveIcsByMessage} — carries a `UID` and parses as a + * decided response via the shared {@link parseIcsReply}. `NEEDS-ACTION` + * yields nothing — there is no response to report. + * + * The per-ICS parse (`METHOD`, `PARTSTAT`, `RECURRENCE-ID`, `COMMENT` / + * `X-RESPONSE-COMMENT`, `ATTENDEE` CN/mailto) is shared with other calendar + * connectors via `@plotday/rsvp-fold`; only the Gmail-shaped bits stay here: + * looping over the conversation's messages, the `UID` used to address the + * event thread, and falling back to the notification body's quoted note when + * the ICS itself carried none. * * Every reply message is returned rather than only the first, so a * conversation carrying a revised response stays correct. @@ -1024,60 +980,27 @@ export function extractCalendarReplies( for (const message of messages) { const ics = icsByMessage.get(message.id); if (!ics) continue; - if ((icsProp(ics, "METHOD") ?? "").toUpperCase() !== "REPLY") continue; const uid = icsProp(ics, "UID"); if (!uid) continue; - const attendeeLine = icsPropLine(ics, "ATTENDEE"); - if (!attendeeLine) continue; - const sep = attendeeLine.lastIndexOf(":"); - const params = parseIcsParams(attendeeLine.slice(0, sep)); - const attendeeEmail = attendeeLine - .slice(sep + 1) - .trim() - .replace(/^mailto:/i, ""); - if (!attendeeEmail) continue; - - const partstat = (params.PARTSTAT ?? "").toUpperCase(); - if ( - partstat !== "DECLINED" && - partstat !== "ACCEPTED" && - partstat !== "TENTATIVE" - ) { - continue; - } - - const recurrenceLine = icsPropLine(ics, "RECURRENCE-ID"); - let occurrence: Date | null = null; - let allDay = false; - if (recurrenceLine) { - const rSep = recurrenceLine.lastIndexOf(":"); - const rParams = parseIcsParams(recurrenceLine.slice(0, rSep)); - allDay = (rParams.VALUE ?? "").toUpperCase() === "DATE"; - occurrence = parseIcsDate(recurrenceLine.slice(rSep + 1)); - } - - const icsComment = icsProp(ics, "COMMENT"); - const comment = - (icsComment ? unescapeIcsText(icsComment).trim() : "") || - (params["X-RESPONSE-COMMENT"] - ? unescapeIcsText(params["X-RESPONSE-COMMENT"]).trim() - : "") || - commentFromBody(message) || - null; + const from = parseEmailAddress(getHeader(message, "From") ?? ""); + const reply = parseIcsReply(ics, { + name: from?.name ?? null, + email: from?.email ?? "", + }); + if (!reply) continue; - const fromName = - parseEmailAddress(getHeader(message, "From") ?? "")?.name ?? null; + const comment = reply.comment ?? commentFromBody(message); replies.push({ messageId: message.id, uid, - partstat, - attendeeName: params.CN?.trim() || fromName || null, - attendeeEmail, - occurrence, - allDay, + partstat: reply.partstat, + attendeeName: reply.attendeeName, + attendeeEmail: reply.attendeeEmail, + occurrence: reply.occurrence, + allDay: reply.allDay, comment, sourceCreatedAt: new Date(Number(message.internalDate)), }); diff --git a/libs/rsvp-fold/src/ics-reply.test.ts b/libs/rsvp-fold/src/ics-reply.test.ts new file mode 100644 index 00000000..b89f2890 --- /dev/null +++ b/libs/rsvp-fold/src/ics-reply.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import { parseIcsReply } from "./ics-reply"; + +const FALLBACK = { name: null, email: "fallback@example.test" }; + +/** + * Real capture from a Google Calendar-generated reply (bare acceptance, no + * comment). Structure — property spellings, escaping, line folding — is + * preserved verbatim; only the organizer/attendee identities are anonymised. + */ +const GOOGLE_ACCEPTED = [ + "BEGIN:VCALENDAR", + "PRODID:-//Google Inc//Google Calendar 70.9054//EN", + "VERSION:2.0", + "CALSCALE:GREGORIAN", + "METHOD:REPLY", + "BEGIN:VEVENT", + "DTSTART:20260803T150000Z", + "DTEND:20260803T153000Z", + "DTSTAMP:20260803T145144Z", + "ORGANIZER;CN=Event Organizer:mailto:organizer@example.test", + "UID:040000008200E00074C5B7101A82E0080000000057E690055723DD01000000000000000", + " 010000000AA3D563406A91946998F2774AAD4D280", + "ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Beth Ro", + " und;X-NUM-GUESTS=0:mailto:beth@example.test", + "CREATED:20260803T145127Z", + "LAST-MODIFIED:20260803T145140Z", + "LOCATION:Microsoft Teams Meeting", + "SEQUENCE:1", + "STATUS:CONFIRMED", + "SUMMARY:Test replies", + "END:VEVENT", + "END:VCALENDAR", +].join("\r\n"); + +/** + * Real capture from a Google Calendar-generated reply carrying a note — the + * note lives in Google's `X-RESPONSE-COMMENT` parameter on the ATTENDEE line, + * not the standard COMMENT property. + */ +const GOOGLE_TENTATIVE = [ + "BEGIN:VCALENDAR", + "PRODID:-//Google Inc//Google Calendar 70.9054//EN", + "VERSION:2.0", + "CALSCALE:GREGORIAN", + "METHOD:REPLY", + "BEGIN:VEVENT", + "DTSTART:20260803T150000Z", + "DTEND:20260803T153000Z", + "DTSTAMP:20260803T145203Z", + "ORGANIZER;CN=Event Organizer:mailto:organizer@example.test", + "UID:040000008200E00074C5B7101A82E0080000000057E690055723DD01000000000000000", + " 010000000AA3D563406A91946998F2774AAD4D280", + "ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Beth ", + ' Round;X-NUM-GUESTS=0;X-RESPONSE-COMMENT="This is my reply for maybe":mailto', + " :beth@example.test", + "CREATED:20260803T145127Z", + "LAST-MODIFIED:20260803T145202Z", + "LOCATION:Microsoft Teams Meeting", + "SEQUENCE:1", + "STATUS:CONFIRMED", + "SUMMARY:Test replies", + "END:VEVENT", + "END:VCALENDAR", +].join("\r\n"); + +/** + * Real capture from a Microsoft Exchange-generated reply — the note lives in + * the standard COMMENT property, and the comment carries an RFC 5545 escaped + * comma and a trailing escaped newline (`\,` and `\n`). + */ +const MS_ACCEPTED = [ + "BEGIN:VCALENDAR", + "METHOD:REPLY", + "PRODID:Microsoft Exchange Server 2010", + "VERSION:2.0", + "BEGIN:VEVENT", + "ATTENDEE;PARTSTAT=ACCEPTED;CN=Ana Ruiz:mailto:ana@example.test", + "COMMENT;LANGUAGE=en-US:Uh huh\\, here's my comment\\n", + "UID:2sfjkg3asr2hofgcgfsi51ks84@google.com", + "SUMMARY;LANGUAGE=en-US:Accepted: Hey outlook", + "DTSTART;TZID=Eastern Standard Time:20260803T113000", + "DTEND;TZID=Eastern Standard Time:20260803T120000", + "SEQUENCE:0", + "X-MICROSOFT-CDO-ALLDAYEVENT:FALSE", + "END:VEVENT", + "END:VCALENDAR", +].join("\r\n"); + +/** Minimal synthetic reply for edge cases the real captures don't exercise. */ +const BASE = [ + "BEGIN:VCALENDAR", + "METHOD:REPLY", + "BEGIN:VEVENT", + "UID:uid-1@example.test", + "ATTENDEE;PARTSTAT=ACCEPTED:mailto:beth@example.test", + "END:VEVENT", + "END:VCALENDAR", +].join("\r\n"); + +describe("parseIcsReply", () => { + it("reads PARTSTAT and attendee from a Google-generated reply", () => { + const r = parseIcsReply(GOOGLE_ACCEPTED, { + name: null, + email: "fallback@example.test", + }); + expect(r).toMatchObject({ + partstat: "ACCEPTED", + attendeeName: "Beth Round", + attendeeEmail: "beth@example.test", + comment: null, + occurrence: null, + }); + }); + + it("reads Google's X-RESPONSE-COMMENT parameter", () => { + const r = parseIcsReply(GOOGLE_TENTATIVE, { + name: null, + email: "fallback@example.test", + }); + expect(r).toMatchObject({ + partstat: "TENTATIVE", + comment: "This is my reply for maybe", + }); + }); + + it("reads Microsoft's COMMENT property", () => { + const r = parseIcsReply(MS_ACCEPTED, { + name: null, + email: "fallback@example.test", + }); + expect(r).toMatchObject({ + partstat: "ACCEPTED", + attendeeName: "Ana Ruiz", + attendeeEmail: "ana@example.test", + comment: "Uh huh, here's my comment", + }); + }); + + it("falls back to the supplied sender when the ATTENDEE has no CN", () => { + const r = parseIcsReply(BASE, { + name: "Fallback Name", + email: "fallback@example.test", + }); + expect(r).toMatchObject({ + attendeeName: "Fallback Name", + // The ATTENDEE line's own mailto still wins over the fallback email. + attendeeEmail: "beth@example.test", + }); + }); + + it("falls back to the supplied email when the ATTENDEE line has no address", () => { + // The property line still ends in a colon (an ATTENDEE line always has + // one) — only the address after `mailto:` is missing. + const noAddress = BASE.replace("mailto:beth@example.test", "mailto:"); + const r = parseIcsReply(noAddress, FALLBACK); + expect(r?.attendeeEmail).toBe("fallback@example.test"); + }); + + it("returns null when neither the ATTENDEE line nor the fallback has an email", () => { + const noAddress = BASE.replace("mailto:beth@example.test", "mailto:"); + const r = parseIcsReply(noAddress, { name: null, email: "" }); + expect(r).toBeNull(); + }); + + it("returns null for a non-REPLY method", () => { + const request = BASE.replace("METHOD:REPLY", "METHOD:REQUEST"); + expect(parseIcsReply(request, FALLBACK)).toBeNull(); + }); + + it("returns null when there is no ATTENDEE line", () => { + const noAttendee = BASE.replace(/^ATTENDEE.*\r\n/m, ""); + expect(parseIcsReply(noAttendee, FALLBACK)).toBeNull(); + }); + + it("returns null for an unrecognised PARTSTAT", () => { + const pending = BASE.replace("PARTSTAT=ACCEPTED", "PARTSTAT=NEEDS-ACTION"); + expect(parseIcsReply(pending, FALLBACK)).toBeNull(); + }); + + it("reads RECURRENCE-ID as the occurrence, and null when absent", () => { + const withOccurrence = BASE.replace( + "END:VEVENT", + "RECURRENCE-ID:20260804T140000Z\r\nEND:VEVENT" + ); + const withOccurrenceReply = parseIcsReply(withOccurrence, FALLBACK); + expect(withOccurrenceReply?.occurrence?.toISOString()).toBe( + "2026-08-04T14:00:00.000Z" + ); + expect(withOccurrenceReply?.allDay).toBe(false); + + const bareReply = parseIcsReply(BASE, FALLBACK); + expect(bareReply?.occurrence).toBeNull(); + }); +}); diff --git a/libs/rsvp-fold/src/ics-reply.ts b/libs/rsvp-fold/src/ics-reply.ts new file mode 100644 index 00000000..07134735 --- /dev/null +++ b/libs/rsvp-fold/src/ics-reply.ts @@ -0,0 +1,158 @@ +/** + * Parses the `METHOD:REPLY` iCalendar body a calendar system sends when an + * attendee accepts, declines, or tentatively accepts an invitation. + * + * Both Google Calendar and Microsoft Exchange emit this shape — they differ + * only in where the responder's personal note lives (a `COMMENT` property vs. + * an `X-RESPONSE-COMMENT` parameter on the `ATTENDEE` line) and this parser + * reads both. A connector resolves the raw ICS text itself (it may arrive + * inline or as an attachment) and supplies a fallback identity for when the + * `ATTENDEE` line omits a `CN`/address of its own. + */ + +import type { RsvpReply } from "./rsvp-note"; + +/** + * Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and + * match one property line: group 1 is its parameter section (leading `;` + * included, or `""` when there are none), group 2 is its value. Shared by + * `icsProp` (value only) and `icsPropLine` (params + value), so the + * unfolding rule and line regex exist exactly once. + */ +function matchIcsLine(ics: string, name: string): RegExpMatchArray | null { + const unfolded = ics.replace(/\r?\n[ \t]/g, ""); + const re = new RegExp(`^${name}((?:;[^:\\r\\n]*)?):(.*)$`, "im"); + return unfolded.match(re); +} + +/** Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and read a property. */ +function icsProp(ics: string, name: string): string | null { + const m = matchIcsLine(ics, name); + return m ? m[2].trim() : null; +} + +/** + * Read a property's raw line (parameters included) from an ICS body. Shares + * `icsProp`'s unfolding and line regex via `matchIcsLine`, but returns + * everything after the property name so parameters can be parsed. + */ +function icsPropLine(ics: string, name: string): string | null { + const m = matchIcsLine(ics, name); + return m ? `${m[1]}:${m[2]}` : null; +} + +/** + * Split an ICS property's parameter section into a map. Values may be quoted + * (`X-RESPONSE-COMMENT="a, b"`), and a quoted value may contain the `;` and + * `:` that otherwise delimit parameters — so scan rather than split. + */ +function parseIcsParams(paramSection: string): Record { + const params: Record = {}; + const re = /;([A-Za-z0-9-]+)=("([^"]*)"|[^;:]*)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(paramSection)) !== null) { + params[m[1].toUpperCase()] = m[3] !== undefined ? m[3] : m[2]; + } + return params; +} + +/** + * Parse an ICS date-time into a UTC instant. Handles `20260804T140000Z` + * (UTC), `20260804T100000` (floating or TZID-qualified — read as UTC, since + * resolving a TZID needs a tz database the caller doesn't carry), and + * `20260804` (VALUE=DATE). + */ +function parseIcsDate(value: string): Date | null { + const m = value + .trim() + .match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/); + if (!m) return null; + const [, y, mo, d, h = "00", mi = "00", s = "00"] = m; + const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + return Number.isNaN(ms) ? null : new Date(ms); +} + +/** + * RFC 5545 text un-escaping: `\n`/`\N` → newline, `\,` `\;` `\\` → the + * literal character. Single-pass so an escaped backslash immediately + * 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. + */ +function unescapeIcsText(value: string): string { + return value.replace(/\\([nN,;\\])/g, (_, ch: string) => + ch === "n" || ch === "N" ? "\n" : ch + ); +} + +/** + * The sender identity to fall back on when the `ATTENDEE` line itself omits a + * `CN` (display name) or, more rarely, an address. Connectors typically parse + * this from the message's own From header. + */ +export type IcsReplyFallback = { + name: string | null; + email: string; +}; + +/** + * Parse one attendee response from a `METHOD:REPLY` iCalendar body. + * + * Returns `null` when the body isn't a reply, carries no usable `ATTENDEE` + * (no address, from the line itself or the supplied fallback), or carries a + * `PARTSTAT` other than `ACCEPTED`/`DECLINED`/`TENTATIVE` (`NEEDS-ACTION` + * means there is no response yet, so it yields nothing). + */ +export function parseIcsReply( + ics: string, + fallback: IcsReplyFallback +): RsvpReply | null { + if ((icsProp(ics, "METHOD") ?? "").toUpperCase() !== "REPLY") return null; + + const attendeeLine = icsPropLine(ics, "ATTENDEE"); + if (!attendeeLine) return null; + const sep = attendeeLine.lastIndexOf(":"); + const params = parseIcsParams(attendeeLine.slice(0, sep)); + const attendeeEmail = + attendeeLine + .slice(sep + 1) + .trim() + .replace(/^mailto:/i, "") || fallback.email; + if (!attendeeEmail) return null; + + const partstat = (params.PARTSTAT ?? "").toUpperCase(); + if ( + partstat !== "DECLINED" && + partstat !== "ACCEPTED" && + partstat !== "TENTATIVE" + ) { + return null; + } + + const recurrenceLine = icsPropLine(ics, "RECURRENCE-ID"); + let occurrence: Date | null = null; + let allDay = false; + if (recurrenceLine) { + const rSep = recurrenceLine.lastIndexOf(":"); + const rParams = parseIcsParams(recurrenceLine.slice(0, rSep)); + allDay = (rParams.VALUE ?? "").toUpperCase() === "DATE"; + occurrence = parseIcsDate(recurrenceLine.slice(rSep + 1)); + } + + const icsComment = icsProp(ics, "COMMENT"); + const comment = + (icsComment ? unescapeIcsText(icsComment).trim() : "") || + (params["X-RESPONSE-COMMENT"] + ? unescapeIcsText(params["X-RESPONSE-COMMENT"]).trim() + : "") || + null; + + return { + partstat: partstat as RsvpReply["partstat"], + attendeeName: params.CN?.trim() || fallback.name || null, + attendeeEmail, + occurrence, + allDay, + comment, + }; +} diff --git a/libs/rsvp-fold/src/index.ts b/libs/rsvp-fold/src/index.ts index 484c9cf1..71f69456 100644 --- a/libs/rsvp-fold/src/index.ts +++ b/libs/rsvp-fold/src/index.ts @@ -4,3 +4,5 @@ export { priorRsvpKey, type RsvpReply, } from "./rsvp-note"; + +export { parseIcsReply, type IcsReplyFallback } from "./ics-reply"; From 72f210b8fbfba5c9c321069ee4388fe1d65a9993 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 11:45:53 -0400 Subject: [PATCH 05/14] Fix review round 1: drop email fallback, share icsProp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseIcsReply no longer falls back to the caller's address when an ATTENDEE line has no resolvable email — a reply with no address isn't from anyone in particular, so it's dropped rather than attributed to whoever merely delivered the notification. The fallback now carries only a display name, matching what Gmail's extractor always did before this parser was shared. Also exports icsProp from the library and has Gmail import it instead of keeping a private copy, so the two connectors' METHOD/UID/PARTSTAT lookups can't silently drift apart. --- connectors/google/src/mail/gmail-api.ts | 31 +++------------- libs/rsvp-fold/src/ics-reply.test.ts | 47 ++++++++++--------------- libs/rsvp-fold/src/ics-reply.ts | 39 +++++++++++--------- libs/rsvp-fold/src/index.ts | 2 +- 4 files changed, 47 insertions(+), 72 deletions(-) diff --git a/connectors/google/src/mail/gmail-api.ts b/connectors/google/src/mail/gmail-api.ts index 424593d5..a1349797 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 { parseIcsReply } from "@plotday/rsvp-fold"; +import { icsProp, parseIcsReply } from "@plotday/rsvp-fold"; export type GmailLabel = { @@ -768,27 +768,6 @@ function normalizeMessageId(raw: string | null): string | null { return match ? match[0] : raw.trim(); } -/** - * Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and - * match one property line: group 1 is its parameter section (leading `;` - * included, or `""` when there are none), group 2 is its value. Used by - * `icsProp` for the property lookups `classifyCalendarThread` still needs - * locally (`METHOD`, `UID`, `SEQUENCE`) — the params+value variant of this - * helper (`icsPropLine`) moved to `@plotday/rsvp-fold` along with the rest of - * the reply parse. - */ -function matchIcsLine(ics: string, name: string): RegExpMatchArray | null { - const unfolded = ics.replace(/\r?\n[ \t]/g, ""); - const re = new RegExp(`^${name}((?:;[^:\\r\\n]*)?):(.*)$`, "im"); - return unfolded.match(re); -} - -/** Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and read a property. */ -function icsProp(ics: string, name: string): string | null { - const m = matchIcsLine(ics, name); - return m ? m[2].trim() : null; -} - /** * MIME types an iCalendar part is delivered under. Google and Exchange use * `text/calendar`; a sizeable minority of senders use `application/ics`. @@ -984,11 +963,9 @@ export function extractCalendarReplies( const uid = icsProp(ics, "UID"); if (!uid) continue; - const from = parseEmailAddress(getHeader(message, "From") ?? ""); - const reply = parseIcsReply(ics, { - name: from?.name ?? null, - email: from?.email ?? "", - }); + const fromName = + parseEmailAddress(getHeader(message, "From") ?? "")?.name ?? null; + const reply = parseIcsReply(ics, { name: fromName }); if (!reply) continue; const comment = reply.comment ?? commentFromBody(message); diff --git a/libs/rsvp-fold/src/ics-reply.test.ts b/libs/rsvp-fold/src/ics-reply.test.ts index b89f2890..5e195b36 100644 --- a/libs/rsvp-fold/src/ics-reply.test.ts +++ b/libs/rsvp-fold/src/ics-reply.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { parseIcsReply } from "./ics-reply"; -const FALLBACK = { name: null, email: "fallback@example.test" }; +const FALLBACK = { name: null }; /** * Real capture from a Google Calendar-generated reply (bare acceptance, no @@ -101,10 +101,7 @@ const BASE = [ describe("parseIcsReply", () => { it("reads PARTSTAT and attendee from a Google-generated reply", () => { - const r = parseIcsReply(GOOGLE_ACCEPTED, { - name: null, - email: "fallback@example.test", - }); + const r = parseIcsReply(GOOGLE_ACCEPTED, { name: null }); expect(r).toMatchObject({ partstat: "ACCEPTED", attendeeName: "Beth Round", @@ -115,10 +112,7 @@ describe("parseIcsReply", () => { }); it("reads Google's X-RESPONSE-COMMENT parameter", () => { - const r = parseIcsReply(GOOGLE_TENTATIVE, { - name: null, - email: "fallback@example.test", - }); + const r = parseIcsReply(GOOGLE_TENTATIVE, { name: null }); expect(r).toMatchObject({ partstat: "TENTATIVE", comment: "This is my reply for maybe", @@ -126,10 +120,7 @@ describe("parseIcsReply", () => { }); it("reads Microsoft's COMMENT property", () => { - const r = parseIcsReply(MS_ACCEPTED, { - name: null, - email: "fallback@example.test", - }); + const r = parseIcsReply(MS_ACCEPTED, { name: null }); expect(r).toMatchObject({ partstat: "ACCEPTED", attendeeName: "Ana Ruiz", @@ -138,30 +129,30 @@ describe("parseIcsReply", () => { }); }); - it("falls back to the supplied sender when the ATTENDEE has no CN", () => { - const r = parseIcsReply(BASE, { - name: "Fallback Name", - email: "fallback@example.test", - }); + it("prefers the COMMENT property over X-RESPONSE-COMMENT when both are present", () => { + // Not a real-world combination (each provider only ever emits one), but + // pins the precedence so a future reordering of the `||` chain is caught. + const both = MS_ACCEPTED.replace( + "ATTENDEE;PARTSTAT=ACCEPTED;CN=Ana Ruiz:mailto:ana@example.test", + 'ATTENDEE;PARTSTAT=ACCEPTED;CN=Ana Ruiz;X-RESPONSE-COMMENT="from the parameter":mailto:ana@example.test' + ); + const r = parseIcsReply(both, { name: null }); + expect(r?.comment).toBe("Uh huh, here's my comment"); + }); + + it("falls back to the supplied sender name when the ATTENDEE has no CN", () => { + const r = parseIcsReply(BASE, { name: "Fallback Name" }); expect(r).toMatchObject({ attendeeName: "Fallback Name", - // The ATTENDEE line's own mailto still wins over the fallback email. attendeeEmail: "beth@example.test", }); }); - it("falls back to the supplied email when the ATTENDEE line has no address", () => { + it("returns null when the ATTENDEE line has no resolvable address (no email fallback — a malformed address drops the reply rather than misattributing it to the notification's sender)", () => { // The property line still ends in a colon (an ATTENDEE line always has // one) — only the address after `mailto:` is missing. const noAddress = BASE.replace("mailto:beth@example.test", "mailto:"); - const r = parseIcsReply(noAddress, FALLBACK); - expect(r?.attendeeEmail).toBe("fallback@example.test"); - }); - - it("returns null when neither the ATTENDEE line nor the fallback has an email", () => { - const noAddress = BASE.replace("mailto:beth@example.test", "mailto:"); - const r = parseIcsReply(noAddress, { name: null, email: "" }); - expect(r).toBeNull(); + expect(parseIcsReply(noAddress, { name: "Notification Sender" })).toBeNull(); }); it("returns null for a non-REPLY method", () => { diff --git a/libs/rsvp-fold/src/ics-reply.ts b/libs/rsvp-fold/src/ics-reply.ts index 07134735..904c3526 100644 --- a/libs/rsvp-fold/src/ics-reply.ts +++ b/libs/rsvp-fold/src/ics-reply.ts @@ -6,8 +6,8 @@ * only in where the responder's personal note lives (a `COMMENT` property vs. * an `X-RESPONSE-COMMENT` parameter on the `ATTENDEE` line) and this parser * reads both. A connector resolves the raw ICS text itself (it may arrive - * inline or as an attachment) and supplies a fallback identity for when the - * `ATTENDEE` line omits a `CN`/address of its own. + * inline or as an attachment) and supplies a fallback display name for when + * the `ATTENDEE` line omits a `CN` of its own. */ import type { RsvpReply } from "./rsvp-note"; @@ -25,8 +25,14 @@ function matchIcsLine(ics: string, name: string): RegExpMatchArray | null { return unfolded.match(re); } -/** Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and read a property. */ -function icsProp(ics: string, name: string): string | null { +/** + * Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and read + * a property's value. General-purpose enough that connectors also use it + * directly for lookups that have nothing to do with a reply (e.g. reading + * `METHOD`/`UID`/`SEQUENCE` to classify a calendar message), so it's exported + * alongside `parseIcsReply` rather than kept private. + */ +export function icsProp(ics: string, name: string): string | null { const m = matchIcsLine(ics, name); return m ? m[2].trim() : null; } @@ -86,22 +92,24 @@ function unescapeIcsText(value: string): string { } /** - * The sender identity to fall back on when the `ATTENDEE` line itself omits a - * `CN` (display name) or, more rarely, an address. Connectors typically parse - * this from the message's own From header. + * The display name to fall back on when the `ATTENDEE` line itself omits a + * `CN`. Connectors typically parse this from the message's own From header. + * There is deliberately no email fallback: an `ATTENDEE` line with no + * resolvable address is not a response from anyone in particular, so it is + * dropped rather than attributed to whoever merely delivered the + * notification (e.g. a calendar system's own notification address). */ export type IcsReplyFallback = { name: string | null; - email: string; }; /** * Parse one attendee response from a `METHOD:REPLY` iCalendar body. * * Returns `null` when the body isn't a reply, carries no usable `ATTENDEE` - * (no address, from the line itself or the supplied fallback), or carries a - * `PARTSTAT` other than `ACCEPTED`/`DECLINED`/`TENTATIVE` (`NEEDS-ACTION` - * means there is no response yet, so it yields nothing). + * (no resolvable address on the line itself), or carries a `PARTSTAT` other + * than `ACCEPTED`/`DECLINED`/`TENTATIVE` (`NEEDS-ACTION` means there is no + * response yet, so it yields nothing). */ export function parseIcsReply( ics: string, @@ -113,11 +121,10 @@ export function parseIcsReply( if (!attendeeLine) return null; const sep = attendeeLine.lastIndexOf(":"); const params = parseIcsParams(attendeeLine.slice(0, sep)); - const attendeeEmail = - attendeeLine - .slice(sep + 1) - .trim() - .replace(/^mailto:/i, "") || fallback.email; + const attendeeEmail = attendeeLine + .slice(sep + 1) + .trim() + .replace(/^mailto:/i, ""); if (!attendeeEmail) return null; const partstat = (params.PARTSTAT ?? "").toUpperCase(); diff --git a/libs/rsvp-fold/src/index.ts b/libs/rsvp-fold/src/index.ts index 71f69456..ba3b0610 100644 --- a/libs/rsvp-fold/src/index.ts +++ b/libs/rsvp-fold/src/index.ts @@ -5,4 +5,4 @@ export { type RsvpReply, } from "./rsvp-note"; -export { parseIcsReply, type IcsReplyFallback } from "./ics-reply"; +export { parseIcsReply, icsProp, type IcsReplyFallback } from "./ics-reply"; From c12ff5de8d13ece566019537b4091f78f11f0278 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 11:59:29 -0400 Subject: [PATCH 06/14] Read an Outlook meeting reply from the message's calendar part Microsoft-generated meeting replies carry a text/calendar; method=REPLY part but no application/ics attachment, so reading only attachments would silently miss every one of them. extractOutlookReply instead reads the message's raw MIME (a new GraphMailApi.getMimeContent, GET .../$value) and locates the calendar part directly, preferring text/calendar over a duplicate application/ics attachment when both are present, and decoding either 7bit or base64 transfer encoding before handing the ICS text to the shared parseIcsReply parser. Also simplifies classifyOutlookCalendar back to a cheap partstat-only pre-filter: the occurrence an RSVP responds to is now read from the reply's own ICS (RECURRENCE-ID) rather than trusted from Graph's event.originalStart/type, so the $expand at both call sites narrows back to iCalUId only. --- .../outlook/src/mail/graph-mail-api.test.ts | 63 +--- connectors/outlook/src/mail/graph-mail-api.ts | 84 ++--- .../src/mail/outlook-ics-reply.test.ts | 326 ++++++++++++++++++ .../outlook/src/mail/outlook-ics-reply.ts | 102 ++++++ 4 files changed, 489 insertions(+), 86 deletions(-) create mode 100644 connectors/outlook/src/mail/outlook-ics-reply.test.ts create mode 100644 connectors/outlook/src/mail/outlook-ics-reply.ts diff --git a/connectors/outlook/src/mail/graph-mail-api.test.ts b/connectors/outlook/src/mail/graph-mail-api.test.ts index c24f7c0a..f2d38bb6 100644 --- a/connectors/outlook/src/mail/graph-mail-api.test.ts +++ b/connectors/outlook/src/mail/graph-mail-api.test.ts @@ -261,7 +261,7 @@ describe("GraphMailApi queries", () => { "microsoft.graph.eventMessage/meetingMessageType" ); expect(calls[0]?.$expand).toBe( - "microsoft.graph.eventMessage/event($select=iCalUId,originalStart,type)" + "microsoft.graph.eventMessage/event($select=iCalUId)" ); }); @@ -281,9 +281,26 @@ describe("GraphMailApi queries", () => { "microsoft.graph.eventMessage/meetingMessageType" ); expect(calls[0]?.$expand).toBe( - "microsoft.graph.eventMessage/event($select=iCalUId,originalStart,type)" + "microsoft.graph.eventMessage/event($select=iCalUId)" ); }); + + it("getMimeContent requests $value and returns the raw text (not JSON-parsed)", async () => { + const calls: Array<{ method: string; url: string }> = []; + const api = new GraphMailApi("tok"); + api.call = async (method: string, url: string) => { + calls.push({ method, url }); + return "MIME-Version: 1.0\r\nFrom: a@b.c\r\n\r\nbody"; + }; + const result = await api.getMimeContent("msg-1"); + expect(calls).toEqual([ + { + method: "GET", + url: "https://graph.microsoft.com/v1.0/me/messages/msg-1/$value", + }, + ]); + expect(result).toBe("MIME-Version: 1.0\r\nFrom: a@b.c\r\n\r\nbody"); + }); }); describe("classifyOutlookCalendar", () => { @@ -361,48 +378,6 @@ describe("classifyOutlookCalendar", () => { } }); - it("carries the occurrence for a response to one instance of a series", () => { - const r = classifyOutlookCalendar( - [ - msg({ - meetingMessageType: "meetingAccepted", - event: { - iCalUId: "u", - type: "occurrence", - originalStart: "2026-08-04T14:00:00Z", - }, - }), - ], - null - ); - expect(r).toMatchObject({ kind: "rsvp", partstat: "ACCEPTED" }); - expect((r as { occurrence: Date }).occurrence).toEqual( - new Date("2026-08-04T14:00:00Z") - ); - }); - - it("leaves occurrence null for a response to the whole series", () => { - const r = classifyOutlookCalendar( - [msg({ meetingMessageType: "meetingAccepted", event: { iCalUId: "u", type: "singleInstance" } })], - null - ); - expect((r as { occurrence: Date | null }).occurrence).toBeNull(); - }); - - it("degrades a malformed originalStart on an occurrence to occurrence: null instead of an Invalid Date", () => { - const r = classifyOutlookCalendar( - [ - msg({ - meetingMessageType: "meetingAccepted", - event: { iCalUId: "u", type: "occurrence", originalStart: "not-a-date" }, - }), - ], - null - ); - expect(r).toMatchObject({ kind: "rsvp", partstat: "ACCEPTED" }); - expect((r as { occurrence: Date | null }).occurrence).toBeNull(); - }); - it("still prefers cancel and request over an rsvp in the same conversation", () => { expect( classifyOutlookCalendar( diff --git a/connectors/outlook/src/mail/graph-mail-api.ts b/connectors/outlook/src/mail/graph-mail-api.ts index 9cbc4dc3..86626881 100644 --- a/connectors/outlook/src/mail/graph-mail-api.ts +++ b/connectors/outlook/src/mail/graph-mail-api.ts @@ -44,7 +44,7 @@ export type GraphMessage = { internetMessageHeaders?: GraphHeader[]; "@odata.type"?: string; meetingMessageType?: string; - event?: { iCalUId?: string; originalStart?: string; type?: string }; + event?: { iCalUId?: string }; }; export type GraphMailFolder = { @@ -188,13 +188,17 @@ export class GraphMailApi { * folder moves — attachment refs and the msg-channel cache depend on it) * plus html body-content. Returns null on 404 (deleted upstream). Retries * once on 429/503 honoring Retry-After (capped 15s). + * + * `raw: true` returns the response body as text without JSON-parsing it — + * for endpoints like `$value` that return `message/rfc822`, not JSON. */ public async call( method: string, url: string, params?: Record, body?: unknown, - extraHeaders?: Record + extraHeaders?: Record, + raw?: boolean ): Promise { const query = params ? `?${new URLSearchParams(params)}` : ""; const headers: Record = { @@ -225,8 +229,9 @@ export class GraphMailApi { await response.text() ); } - if (response.status === 202 || response.status === 204) return {}; + if (response.status === 202 || response.status === 204) return raw ? "" : {}; const text = await response.text(); + if (raw) return text; return text ? JSON.parse(text) : {}; } } @@ -304,11 +309,10 @@ export class GraphMailApi { // 'event' on type 'microsoft.graph.message'". The OData type-cast // segment scopes the expand to items that are actually eventMessages; // plain messages just come back without an `event` field. - // originalStart + type let classifyOutlookCalendar tell a response to - // one occurrence of a recurring series apart from a series-wide - // response — both share the same iCalUId. - $expand: - "microsoft.graph.eventMessage/event($select=iCalUId,originalStart,type)", + // iCalUId is all classifyOutlookCalendar needs — the occurrence an + // RSVP responded to is read from the reply's own ICS + // (RECURRENCE-ID) instead of trusted from Graph. + $expand: "microsoft.graph.eventMessage/event($select=iCalUId)", }; if (args.since) { params.$filter = `receivedDateTime ge ${args.since.toISOString()}`; @@ -350,11 +354,10 @@ export class GraphMailApi { $filter: `conversationId eq ${odataQuote(conversationId)}`, $top: "100", $select: MESSAGE_SELECT_COLLECTION, - // originalStart + type let classifyOutlookCalendar tell a response to - // one occurrence of a recurring series apart from a series-wide - // response — both share the same iCalUId. - $expand: - "microsoft.graph.eventMessage/event($select=iCalUId,originalStart,type)", + // iCalUId is all classifyOutlookCalendar needs — the occurrence an + // RSVP responded to is read from the reply's own ICS (RECURRENCE-ID) + // instead of trusted from Graph. + $expand: "microsoft.graph.eventMessage/event($select=iCalUId)", }); for (let page = 0; page < 5; page++) { messages.push(...((data?.value as GraphMessage[] | undefined) ?? [])); @@ -376,6 +379,25 @@ export class GraphMailApi { return data?.internetMessageHeaders ?? null; } + /** + * The message's raw MIME source (`GET .../$value`), as text. Needed for + * reading a meeting reply's `text/calendar` part: a Microsoft-generated + * reply carries no `application/ics` attachment at all, so + * `listAttachments`/`getAttachment` would silently miss it — see + * `extractOutlookReply` in `outlook-ics-reply.ts`. Returns null on 404 + * (message deleted upstream, same as the other single-message getters). + */ + async getMimeContent(messageId: string): Promise { + return this.call( + "GET", + `${GRAPH}/me/messages/${encodeURIComponent(messageId)}/$value`, + undefined, + undefined, + undefined, + true + ); + } + async listAttachments(messageId: string): Promise { const data = (await this.call( "GET", @@ -666,22 +688,6 @@ const RSVP_PARTSTAT: Record = { meetingTentativelyAccepted: "TENTATIVE", }; -/** - * Parse Graph's `originalStart` (an ISO 8601 instant) into a `Date`, - * validating rather than trusting `new Date(string)` — a malformed or - * truncated value yields `Invalid Date`, not a thrown error, and that value - * would otherwise flow into `RsvpReply.occurrence` and reach - * `priorRsvpKey`'s unconditional `occurrence.toISOString()`, throwing - * `RangeError: Invalid time value` and crashing the sync pass. `null` here - * degrades to a series-scoped key instead — the same safe outcome as a - * genuine whole-series response (see `parseIcsDate` in the Gmail connector - * for the same pattern against ICS dates). - */ -function parseOriginalStart(value: string): Date | null { - const ms = Date.parse(value); - return Number.isNaN(ms) ? null : new Date(ms); -} - /** * Classify an Outlook conversation's relationship to a calendar event for * bundling onto the event's Plot thread. Two signals: our own @@ -697,10 +703,12 @@ function parseOriginalStart(value: string): Date | null { * Cancel and update are checked across the whole conversation before RSVP is * considered at all, in a separate pass — so a conversation carrying both a * response and a cancellation/update still classifies as the stronger kind - * regardless of which message comes first. RSVP responses - * (accept/decline/tentative) carry the occurrence they responded to (`null` - * for a whole-series response) so callers can key dedup state per-occurrence - * rather than per-series. + * regardless of which message comes first. The `partstat` on an RSVP + * classification is a cheap pre-filter for deciding a message is worth an + * ICS fetch — the fold itself must prefer the value read from the reply's + * own ICS (`parseIcsReply`), which is also where the occurrence a response + * targets (`RECURRENCE-ID`) is read from; Graph's own metadata carries + * neither reliably enough to be a source of truth. */ export function classifyOutlookCalendar( messages: GraphMessage[], @@ -709,7 +717,6 @@ export function classifyOutlookCalendar( uid: string; kind: "reply" | "update" | "cancel" | "rsvp"; partstat?: RsvpReply["partstat"]; - occurrence?: Date | null; } | null { const hdr = (parentHeaders ?? []).find( (h) => h.name.toLowerCase() === "x-plot-event-uid" @@ -727,14 +734,7 @@ export function classifyOutlookCalendar( const partstat = m.meetingMessageType ? RSVP_PARTSTAT[m.meetingMessageType] : undefined; - if (partstat) { - const occurrence = - (m.event?.type === "occurrence" || m.event?.type === "exception") && - m.event?.originalStart - ? parseOriginalStart(m.event.originalStart) - : null; - return { uid, kind: "rsvp", partstat, occurrence }; - } + if (partstat) return { uid, kind: "rsvp", partstat }; } return null; } diff --git a/connectors/outlook/src/mail/outlook-ics-reply.test.ts b/connectors/outlook/src/mail/outlook-ics-reply.test.ts new file mode 100644 index 00000000..33beaadd --- /dev/null +++ b/connectors/outlook/src/mail/outlook-ics-reply.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from "vitest"; + +import { extractOutlookReply } from "./outlook-ics-reply"; + +const CRLF = "\r\n"; + +/** + * Real capture from a Google Calendar-generated reply (bare acceptance, no + * comment) — same anonymised content as `@plotday/rsvp-fold`'s + * `ics-reply.test.ts` GOOGLE_ACCEPTED. Structure — property spellings, + * escaping, line folding — is preserved verbatim; only identities are + * anonymised. + */ +const GOOGLE_ICS_ACCEPTED = [ + "BEGIN:VCALENDAR", + "PRODID:-//Google Inc//Google Calendar 70.9054//EN", + "VERSION:2.0", + "CALSCALE:GREGORIAN", + "METHOD:REPLY", + "BEGIN:VEVENT", + "DTSTART:20260803T150000Z", + "DTEND:20260803T153000Z", + "DTSTAMP:20260803T145144Z", + "ORGANIZER;CN=Event Organizer:mailto:organizer@example.test", + "UID:040000008200E00074C5B7101A82E0080000000057E690055723DD01000000000000000", + " 010000000AA3D563406A91946998F2774AAD4D280", + "ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Beth Ro", + " und;X-NUM-GUESTS=0:mailto:beth@example.test", + "CREATED:20260803T145127Z", + "LAST-MODIFIED:20260803T145140Z", + "LOCATION:Microsoft Teams Meeting", + "SEQUENCE:1", + "STATUS:CONFIRMED", + "SUMMARY:Test replies", + "END:VEVENT", + "END:VCALENDAR", +].join(CRLF); + +/** + * Real capture from a Google Calendar-generated reply carrying a note — the + * note lives in Google's `X-RESPONSE-COMMENT` parameter, not the standard + * `COMMENT` property. Same anonymised content as the library's + * GOOGLE_TENTATIVE. + */ +const GOOGLE_ICS_TENTATIVE = [ + "BEGIN:VCALENDAR", + "PRODID:-//Google Inc//Google Calendar 70.9054//EN", + "VERSION:2.0", + "CALSCALE:GREGORIAN", + "METHOD:REPLY", + "BEGIN:VEVENT", + "DTSTART:20260803T150000Z", + "DTEND:20260803T153000Z", + "DTSTAMP:20260803T145203Z", + "ORGANIZER;CN=Event Organizer:mailto:organizer@example.test", + "UID:040000008200E00074C5B7101A82E0080000000057E690055723DD01000000000000000", + " 010000000AA3D563406A91946998F2774AAD4D280", + "ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Beth ", + ' Round;X-NUM-GUESTS=0;X-RESPONSE-COMMENT="This is my reply for maybe":mailto', + " :beth@example.test", + "CREATED:20260803T145127Z", + "LAST-MODIFIED:20260803T145202Z", + "LOCATION:Microsoft Teams Meeting", + "SEQUENCE:1", + "STATUS:CONFIRMED", + "SUMMARY:Test replies", + "END:VEVENT", + "END:VCALENDAR", +].join(CRLF); + +/** + * Real capture from a Microsoft Exchange-generated reply — the note lives + * in the standard COMMENT property. Same anonymised content as the + * library's MS_ACCEPTED. + */ +const MS_ICS_ACCEPTED = [ + "BEGIN:VCALENDAR", + "METHOD:REPLY", + "PRODID:Microsoft Exchange Server 2010", + "VERSION:2.0", + "BEGIN:VEVENT", + "ATTENDEE;PARTSTAT=ACCEPTED;CN=Ana Ruiz:mailto:ana@example.test", + "COMMENT;LANGUAGE=en-US:Uh huh\\, here's my comment\\n", + "UID:2sfjkg3asr2hofgcgfsi51ks84@google.com", + "SUMMARY;LANGUAGE=en-US:Accepted: Hey outlook", + "DTSTART;TZID=Eastern Standard Time:20260803T113000", + "DTEND;TZID=Eastern Standard Time:20260803T120000", + "SEQUENCE:0", + "X-MICROSOFT-CDO-ALLDAYEVENT:FALSE", + "END:VEVENT", + "END:VCALENDAR", +].join(CRLF); + +/** + * A synthetic, differently-shaped reply — not a real capture — used only to + * prove the duplicate-attachment precedence test actually exercises + * precedence rather than two identical parts happening to agree. + */ +const OTHER_ICS_DECLINED = [ + "BEGIN:VCALENDAR", + "METHOD:REPLY", + "BEGIN:VEVENT", + "UID:uid-other@example.test", + "ATTENDEE;PARTSTAT=DECLINED;CN=Someone Else:mailto:someone-else@example.test", + "END:VEVENT", + "END:VCALENDAR", +].join(CRLF); + +const FALLBACK = { name: null, email: "organizer@example.test" }; + +/** Base64-encode ASCII text the same way a mail client would for a `base64` part. */ +function b64(text: string): string { + return btoa(text); +} + +/** One MIME part: headers + a blank line + body, CRLF throughout. */ +function mimePart(headers: string[], body: string): string { + return headers.join(CRLF) + CRLF + CRLF + body; +} + +/** + * A `multipart/mixed` Google-shaped message: a `multipart/alternative` + * (text/plain, text/html, text/calendar) plus a sibling `application/ics` + * attachment — the real captured shape (see fixtures README). + */ +function googleShapedMessage(opts: { + calendarEncoding: "7bit" | "base64"; + calendarIcs: string; + attachmentIcs?: string; +}): string { + const innerBoundary = "inner_boundary_0001"; + const outerBoundary = "outer_boundary_0002"; + + const plainPart = mimePart( + [ + 'Content-Type: text/plain; charset="UTF-8"', + "Content-Transfer-Encoding: base64", + ], + b64("Beth Round has accepted this invitation.") + ); + const htmlPart = mimePart( + ['Content-Type: text/html; charset="UTF-8"', "Content-Transfer-Encoding: quoted-printable"], + "

Beth Round has accepted this invitation.

" + ); + const calendarBody = + opts.calendarEncoding === "base64" + ? b64(opts.calendarIcs) + : opts.calendarIcs; + const calendarPart = mimePart( + [ + 'Content-Type: text/calendar; method=REPLY; charset="UTF-8"', + `Content-Transfer-Encoding: ${opts.calendarEncoding}`, + ], + calendarBody + ); + + const alternative = + `Content-Type: multipart/alternative; boundary="${innerBoundary}"${CRLF}${CRLF}` + + [plainPart, htmlPart, calendarPart] + .map((p) => `--${innerBoundary}${CRLF}${p}`) + .join(CRLF) + + CRLF + + `--${innerBoundary}--`; + + const attachmentPart = mimePart( + [ + 'Content-Type: application/ics; name="invite.ics"', + 'Content-Disposition: attachment; filename="invite.ics"', + "Content-Transfer-Encoding: base64", + ], + b64(opts.attachmentIcs ?? opts.calendarIcs) + ); + + const mixedBody = + `--${outerBoundary}${CRLF}${alternative}${CRLF}` + + `--${outerBoundary}${CRLF}${attachmentPart}${CRLF}` + + `--${outerBoundary}--`; + + return ( + [ + "MIME-Version: 1.0", + "From: Beth Round ", + "To: Plot Test ", + "Subject: Accepted: Test replies @ Mon 2026-08-03 11am - 11:30am (EDT) (Plot Test)", + "Date: Mon, 3 Aug 2026 14:51:44 +0000", + "Message-ID: ", + `Content-Type: multipart/mixed; boundary="${outerBoundary}"`, + ].join(CRLF) + + CRLF + + CRLF + + mixedBody + ); +} + +/** + * A `multipart/alternative` Microsoft-shaped message — text/plain, + * text/html, text/calendar (base64) — and critically NO attachment part at + * all (per the fixtures README, `X-MS-Has-Attach` is empty for these). + */ +function microsoftShapedMessage(icsBody: string): string { + const boundary = "ms_boundary_0003"; + const plainPart = mimePart( + ['Content-Type: text/plain; charset="us-ascii"', "Content-Transfer-Encoding: base64"], + b64('Ana Ruiz has accepted this invitation with a note: "Uh huh, here\'s my comment"') + ); + const htmlPart = mimePart( + ['Content-Type: text/html; charset="us-ascii"', "Content-Transfer-Encoding: quoted-printable"], + "

Ana Ruiz has accepted this invitation.

" + ); + const calendarPart = mimePart( + ['Content-Type: text/calendar; method=REPLY; charset="us-ascii"', "Content-Transfer-Encoding: base64"], + b64(icsBody) + ); + + const body = + [plainPart, htmlPart, calendarPart] + .map((p) => `--${boundary}${CRLF}${p}`) + .join(CRLF) + + CRLF + + `--${boundary}--`; + + return ( + [ + "MIME-Version: 1.0", + "From: Ana Ruiz ", + "To: Plot Test ", + "Subject: Accepted: Hey outlook", + "Date: Mon, 3 Aug 2026 15:30:00 +0000", + "Message-ID: ", + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ].join(CRLF) + + CRLF + + CRLF + + body + ); +} + +/** A plain message with no calendar part at all (an ordinary reply). */ +function plainMessage(): string { + const boundary = "plain_boundary_0004"; + const plainPart = mimePart( + ["Content-Type: text/plain; charset=\"UTF-8\"", "Content-Transfer-Encoding: 7bit"], + "Sounds good, see you then!" + ); + const htmlPart = mimePart( + ["Content-Type: text/html; charset=\"UTF-8\"", "Content-Transfer-Encoding: 7bit"], + "

Sounds good, see you then!

" + ); + const body = + [plainPart, htmlPart].map((p) => `--${boundary}${CRLF}${p}`).join(CRLF) + + CRLF + + `--${boundary}--`; + + return ( + [ + "MIME-Version: 1.0", + "From: Beth Round ", + "To: Plot Test ", + "Subject: Re: Test replies", + "Date: Mon, 3 Aug 2026 16:00:00 +0000", + "Message-ID: ", + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ].join(CRLF) + + CRLF + + CRLF + + body + ); +} + +describe("extractOutlookReply", () => { + it("extracts the reply from a Google-generated message's text/calendar part", () => { + const mime = googleShapedMessage({ + calendarEncoding: "7bit", + calendarIcs: GOOGLE_ICS_ACCEPTED, + }); + const reply = extractOutlookReply(mime, FALLBACK); + expect(reply).toMatchObject({ + partstat: "ACCEPTED", + attendeeName: "Beth Round", + attendeeEmail: "beth@example.test", + comment: null, + }); + }); + + it("extracts the reply from a Microsoft-generated message with NO attachment", () => { + const mime = microsoftShapedMessage(MS_ICS_ACCEPTED); + const reply = extractOutlookReply(mime, FALLBACK); + expect(reply).toMatchObject({ + partstat: "ACCEPTED", + attendeeName: "Ana Ruiz", + attendeeEmail: "ana@example.test", + comment: "Uh huh, here's my comment", + }); + }); + + it("prefers the text/calendar part over a duplicate application/ics attachment", () => { + const mime = googleShapedMessage({ + calendarEncoding: "7bit", + calendarIcs: GOOGLE_ICS_ACCEPTED, // ACCEPTED / Beth Round + attachmentIcs: OTHER_ICS_DECLINED, // DECLINED / Someone Else + }); + const reply = extractOutlookReply(mime, FALLBACK); + expect(reply).toMatchObject({ + partstat: "ACCEPTED", + attendeeName: "Beth Round", + attendeeEmail: "beth@example.test", + }); + }); + + it("decodes a base64 text/calendar part", () => { + const mime = googleShapedMessage({ + calendarEncoding: "base64", + calendarIcs: GOOGLE_ICS_TENTATIVE, + }); + const reply = extractOutlookReply(mime, FALLBACK); + expect(reply).toMatchObject({ + partstat: "TENTATIVE", + comment: "This is my reply for maybe", + }); + }); + + it("returns null when the message carries no calendar part", () => { + const mime = plainMessage(); + expect(extractOutlookReply(mime, FALLBACK)).toBeNull(); + }); +}); diff --git a/connectors/outlook/src/mail/outlook-ics-reply.ts b/connectors/outlook/src/mail/outlook-ics-reply.ts new file mode 100644 index 00000000..c0796cfd --- /dev/null +++ b/connectors/outlook/src/mail/outlook-ics-reply.ts @@ -0,0 +1,102 @@ +/** + * Read an attendee's response to a meeting from an Outlook message's raw + * MIME source. + * + * Both Google- and Microsoft-generated replies carry a + * `text/calendar; method=REPLY` part with the structured PARTSTAT/COMMENT + * fields `parseIcsReply` (from `@plotday/rsvp-fold`) needs. Only + * Google-generated replies ALSO duplicate that part as an `application/ics` + * attachment — a Microsoft-generated reply has no attachment at all + * (`hasAttachments` / `X-MS-Has-Attach` is empty). So this reads the + * message's raw MIME ($value) rather than going through + * `listAttachments`/`getAttachment`, which would silently miss every + * Microsoft-generated response. + */ + +import { parseIcsReply, type RsvpReply } from "@plotday/rsvp-fold"; + +/** One leaf (non-multipart) MIME body part: its Content-Type, transfer encoding, and still-encoded text. */ +type MimePart = { + contentType: string; + transferEncoding: string; + body: string; +}; + +/** Split a MIME message (or one of its parts) into its header block and body at the first blank line. */ +function splitHeadersAndBody(raw: string): { headers: string; body: string } { + const m = raw.match(/\r?\n\r?\n/); + if (!m || m.index === undefined) return { headers: raw, body: "" }; + return { + headers: raw.slice(0, m.index), + body: raw.slice(m.index + m[0].length), + }; +} + +/** Read one header's value, unfolding continuation lines (CRLF + leading whitespace) first. */ +function getHeader(headers: string, name: string): string | null { + const unfolded = headers.replace(/\r?\n[ \t]+/g, " "); + const m = unfolded.match(new RegExp(`^${name}:[ \t]*(.*)$`, "im")); + return m ? m[1].trim() : null; +} + +/** The `boundary` parameter off a `Content-Type: multipart/...` header value. */ +function boundaryOf(contentType: string): string | null { + const m = contentType.match(/boundary="?([^";]+)"?/i); + return m ? m[1] : null; +} + +/** + * Recursively collect every leaf part of a MIME message. Outlook's own + * replies nest at most one level deep (`multipart/mixed` wrapping + * `multipart/alternative`), so this only needs to recurse into whatever + * nesting is actually present — it is not a general-purpose MIME parser. + */ +function collectParts(raw: string): MimePart[] { + const { headers, body } = splitHeadersAndBody(raw); + const contentType = getHeader(headers, "Content-Type") ?? "text/plain"; + if (/^multipart\//i.test(contentType)) { + const boundary = boundaryOf(contentType); + if (!boundary) return []; + // Drop the preamble (before the first delimiter) and the epilogue + // (after the closing `--boundary--`). + const segments = body.split(`--${boundary}`).slice(1, -1); + return segments.flatMap((segment) => + collectParts(segment.replace(/^\r?\n/, "")) + ); + } + return [ + { + contentType, + transferEncoding: + getHeader(headers, "Content-Transfer-Encoding") ?? "7bit", + body, + }, + ]; +} + +/** Decode a part's body per its Content-Transfer-Encoding — only the two encodings this reply shape uses. */ +function decodeBody(part: MimePart): string { + if (part.transferEncoding.toLowerCase() === "base64") { + return atob(part.body.replace(/[\r\n]/g, "")); + } + return part.body; +} + +/** + * Extract an attendee's response from an Outlook message's raw MIME. + * `text/calendar` is preferred over a duplicate `application/ics` + * attachment when both are present. Returns `null` when the message carries + * neither (not a meeting response at all) or when the calendar part itself + * doesn't parse as a reply (see `parseIcsReply`). + */ +export function extractOutlookReply( + mime: string, + fallback: { name: string | null; email: string } +): RsvpReply | null { + const parts = collectParts(mime); + const calendarPart = + parts.find((p) => /text\/calendar/i.test(p.contentType)) ?? + parts.find((p) => /application\/ics/i.test(p.contentType)); + if (!calendarPart) return null; + return parseIcsReply(decodeBody(calendarPart), { name: fallback.name }); +} From 71740a25e167f0190247e1fdeb6085048afe5585 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 12:09:00 -0400 Subject: [PATCH 07/14] Fix review round 1: test call()'s raw path through a real fetch mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMimeContent's only test stubbed call() itself, which meant it never ran call()'s new "return raw text, skip JSON.parse" branch — a regression there (or in threading raw:true through to call()) would have left the suite green. Replaced it with a mocked fetch so the real call() implementation runs against a MIME body that is deliberately not valid JSON; also added a 404-returns-null case. Also added two malformed-MIME cases (no boundary= parameter; a truncated body with no closing delimiter) proving extractOutlookReply degrades to null instead of throwing, and a one-line doc note on extractOutlookReply explaining why fallback.email is accepted but currently unused. --- .../outlook/src/mail/graph-mail-api.test.ts | 52 +++++++++++++------ .../src/mail/outlook-ics-reply.test.ts | 25 +++++++++ .../outlook/src/mail/outlook-ics-reply.ts | 7 +++ 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/connectors/outlook/src/mail/graph-mail-api.test.ts b/connectors/outlook/src/mail/graph-mail-api.test.ts index f2d38bb6..93e2fe3a 100644 --- a/connectors/outlook/src/mail/graph-mail-api.test.ts +++ b/connectors/outlook/src/mail/graph-mail-api.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { classifyOutlookCalendar, conversationSource, @@ -16,6 +16,8 @@ import { type GraphMessage, } from "./graph-mail-api"; +afterEach(() => vi.unstubAllGlobals()); + const msg = (over: Partial): GraphMessage => ({ id: "id-1", conversationId: "conv-1", @@ -285,21 +287,39 @@ describe("GraphMailApi queries", () => { ); }); - it("getMimeContent requests $value and returns the raw text (not JSON-parsed)", async () => { - const calls: Array<{ method: string; url: string }> = []; - const api = new GraphMailApi("tok"); - api.call = async (method: string, url: string) => { - calls.push({ method, url }); - return "MIME-Version: 1.0\r\nFrom: a@b.c\r\n\r\nbody"; - }; - const result = await api.getMimeContent("msg-1"); - expect(calls).toEqual([ - { - method: "GET", - url: "https://graph.microsoft.com/v1.0/me/messages/msg-1/$value", - }, - ]); - expect(result).toBe("MIME-Version: 1.0\r\nFrom: a@b.c\r\n\r\nbody"); + it("getMimeContent requests $value and returns the raw MIME text intact", async () => { + // Exercises the REAL call() implementation (fetch is mocked, call() is + // not) — a raw MIME body is never valid JSON, so this only passes if + // call()'s `raw` branch actually returns response.text() instead of + // falling through to JSON.parse(text), and if getMimeContent actually + // threads `raw: true` through to call(). A stubbed call() (as the + // other tests in this file use for asserting request shape) can't + // catch either regression, since it never runs call()'s body at all. + const rawMime = + "MIME-Version: 1.0\r\nFrom: a@b.c\r\nContent-Type: text/calendar; method=REPLY\r\n\r\nBEGIN:VCALENDAR\r\nEND:VCALENDAR"; + const fetchMock = vi.fn(async (input: string | URL) => { + expect(String(input)).toBe( + "https://graph.microsoft.com/v1.0/me/messages/msg-1/$value" + ); + // Deliberately NOT valid JSON — proves call() didn't JSON.parse it. + expect(() => JSON.parse(rawMime)).toThrow(); + return new Response(rawMime, { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await new GraphMailApi("tok").getMimeContent("msg-1"); + + expect(result).toBe(rawMime); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("getMimeContent returns null on 404 (message deleted upstream)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("gone", { status: 404 })) + ); + const result = await new GraphMailApi("tok").getMimeContent("msg-1"); + expect(result).toBeNull(); }); }); diff --git a/connectors/outlook/src/mail/outlook-ics-reply.test.ts b/connectors/outlook/src/mail/outlook-ics-reply.test.ts index 33beaadd..4b3fe995 100644 --- a/connectors/outlook/src/mail/outlook-ics-reply.test.ts +++ b/connectors/outlook/src/mail/outlook-ics-reply.test.ts @@ -323,4 +323,29 @@ describe("extractOutlookReply", () => { const mime = plainMessage(); expect(extractOutlookReply(mime, FALLBACK)).toBeNull(); }); + + it("degrades safely (returns null, does not throw) on malformed MIME: a multipart Content-Type with no boundary= parameter", () => { + const mime = [ + "MIME-Version: 1.0", + "From: Beth Round ", + "Content-Type: multipart/mixed", + "", + "whatever body — there is no boundary to split on", + ].join(CRLF); + expect(() => extractOutlookReply(mime, FALLBACK)).not.toThrow(); + expect(extractOutlookReply(mime, FALLBACK)).toBeNull(); + }); + + it("degrades safely (returns null, does not throw) on a truncated multipart body (no closing boundary, no parts at all)", () => { + const mime = [ + "MIME-Version: 1.0", + "From: Beth Round ", + 'Content-Type: multipart/mixed; boundary="cut_off_boundary"', + "", + "the connection dropped mid-download and this body was never a real", + "multipart payload — no --cut_off_boundary delimiter appears anywhere", + ].join(CRLF); + expect(() => extractOutlookReply(mime, FALLBACK)).not.toThrow(); + expect(extractOutlookReply(mime, FALLBACK)).toBeNull(); + }); }); diff --git a/connectors/outlook/src/mail/outlook-ics-reply.ts b/connectors/outlook/src/mail/outlook-ics-reply.ts index c0796cfd..0d1455fe 100644 --- a/connectors/outlook/src/mail/outlook-ics-reply.ts +++ b/connectors/outlook/src/mail/outlook-ics-reply.ts @@ -88,6 +88,13 @@ function decodeBody(part: MimePart): string { * attachment when both are present. Returns `null` when the message carries * neither (not a meeting response at all) or when the calendar part itself * doesn't parse as a reply (see `parseIcsReply`). + * + * `fallback.email` is currently unused — only `.name` reaches + * `parseIcsReply`, which deliberately has no email fallback (Task 3's + * `IcsReplyFallback`): an `ATTENDEE` line with no resolvable address is + * dropped rather than misattributed to whoever merely delivered the + * notification. `.email` is accepted here anyway so a caller can pass the + * message's `From` (name + address) straight through without destructuring. */ export function extractOutlookReply( mime: string, From 69fe2c1083cd2c6c475fc7a9140ca9f1dd4811ae Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 12:25:07 -0400 Subject: [PATCH 08/14] Fold Outlook meeting replies onto the event's thread --- connectors/outlook/src/mail/graph-mail-api.ts | 9 +- connectors/outlook/src/mail/sync.test.ts | 492 +++++++++++++++++- connectors/outlook/src/mail/sync.ts | 156 ++++++ libs/rsvp-fold/src/rsvp-note.ts | 13 +- 4 files changed, 660 insertions(+), 10 deletions(-) diff --git a/connectors/outlook/src/mail/graph-mail-api.ts b/connectors/outlook/src/mail/graph-mail-api.ts index 86626881..1799f623 100644 --- a/connectors/outlook/src/mail/graph-mail-api.ts +++ b/connectors/outlook/src/mail/graph-mail-api.ts @@ -681,8 +681,13 @@ export function sortConversation(messages: GraphMessage[]): GraphMessage[] { ); } -/** Graph's meeting-response type → the shared fold rule's `partstat`. */ -const RSVP_PARTSTAT: Record = { +/** + * Graph's meeting-response type → the shared fold rule's `partstat`. Exported + * so the mail sync's fold step can use the same mapping as a cheap pre-filter + * for deciding which messages are worth an extra MIME fetch, without + * duplicating this table. + */ +export const RSVP_PARTSTAT: Record = { meetingAccepted: "ACCEPTED", meetingDeclined: "DECLINED", meetingTentativelyAccepted: "TENTATIVE", diff --git a/connectors/outlook/src/mail/sync.test.ts b/connectors/outlook/src/mail/sync.test.ts index 5960e3a3..431e60cd 100644 --- a/connectors/outlook/src/mail/sync.test.ts +++ b/connectors/outlook/src/mail/sync.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { CreateLinkDraft } from "@plotday/twister"; +import type { CreateLinkDraft, NewLinkWithNotes } from "@plotday/twister"; +import { priorRsvpKey } from "@plotday/rsvp-fold"; /** * Regression coverage for Gmail-alias-aware self-exclusion in the Outlook @@ -19,6 +20,7 @@ const { graphApi } = vi.hoisted(() => ({ updateMessage: vi.fn(), getMessage: vi.fn(), getConversationMessages: vi.fn(), + getMimeContent: vi.fn(), send: vi.fn(), }, })); @@ -27,7 +29,13 @@ vi.mock("./graph-mail-api", async (importOriginal) => { return { ...actual, GraphMailApi: vi.fn(() => graphApi) }; }); // ensureUserEmailFn reads user_email from store; seed it to avoid a getProfile call. -import { onCreateLinkFn, onNoteCreatedFn } from "./sync"; +import { + onCreateLinkFn, + onNoteCreatedFn, + processConversationsFn, + type OutlookMailSyncHost, +} from "./sync"; +import type { GraphMessage } from "./graph-mail-api"; beforeEach(() => { vi.clearAllMocks(); @@ -231,3 +239,483 @@ describe("onNoteCreatedFn — calendar reply whose curated recipients are all se }); }); }); + +// --------------------------------------------------------------------------- +// processConversationsFn — attendee responses fold onto the event thread +// --------------------------------------------------------------------------- + +/** Minimal in-memory OutlookMailSyncHost for processConversationsFn. */ +function makeFoldHost(initial: Record = {}) { + const map = new Map( + Object.entries({ + enabled_channels: ["inbox"], + user_email: "organizer@example.test", + // Non-empty so getWellKnownFn's cache hit skips getWellKnownFolderIds — + // irrelevant here anyway since every call below passes forceChannelId. + wellknown_folders: { inbox: "folder-inbox-id" }, + ...initial, + }) + ); + const host = { + id: "ti-1", + get: vi.fn(async (k: string) => (map.has(k) ? map.get(k) : null)), + set: vi.fn(async (k: string, v: unknown) => { + map.set(k, v); + }), + setMany: vi.fn(async (entries: [string, unknown][]) => { + for (const [k, v] of entries) map.set(k, v); + }), + clear: vi.fn(async (k: string) => { + map.delete(k); + }), + tools: { + integrations: { + get: vi.fn(async () => ({ token: "tok", scopes: [] })), + saveLink: vi.fn(async () => "T"), + saveNote: vi.fn(async () => "N"), + channelSyncCompleted: vi.fn(async () => {}), + setThreadToDo: vi.fn(async () => {}), + }, + files: { read: vi.fn() }, + network: { createWebhook: vi.fn(), deleteWebhook: vi.fn() }, + store: { + acquireLock: vi.fn(async () => true), + releaseLock: vi.fn(async () => {}), + list: vi.fn(async () => []), + }, + }, + scheduler: { + onOutlookMailWebhook: undefined, + setupMailboxSubscription: vi.fn(async () => {}), + renewMailboxSubscription: vi.fn(async () => {}), + scheduleMailboxRenewal: vi.fn(async () => {}), + scheduleSelfHealCheck: vi.fn(async () => {}), + cancelScheduledTask: vi.fn(async () => {}), + scheduleDrain: vi.fn(async () => {}), + queueRenewSubscription: vi.fn(async () => {}), + requeueInitialSync: vi.fn(async () => {}), + }, + } as unknown as OutlookMailSyncHost; + return { host, map }; +} + +/** Capture every saveNote/saveLink call the sync makes. */ +function captureSaves( + host: OutlookMailSyncHost, + opts: { noteId?: string | null } = {} +) { + const notes: Record[] = []; + const links: NewLinkWithNotes[] = []; + ( + host.tools.integrations.saveNote as ReturnType + ).mockImplementation(async (n: Record) => { + notes.push(n); + return opts.noteId === undefined ? "N" : opts.noteId; + }); + ( + host.tools.integrations.saveLink as ReturnType + ).mockImplementation(async (l: NewLinkWithNotes) => { + links.push(l); + return "T"; + }); + return { notes, links }; +} + +/** A calendar-reply ICS body for one attendee response. */ +function replyIcs( + partstat: "DECLINED" | "ACCEPTED" | "TENTATIVE", + opts: { uid?: string; comment?: string; recurrenceId?: string } = {} +): string { + const lines = [ + "BEGIN:VCALENDAR", + "METHOD:REPLY", + "BEGIN:VEVENT", + `UID:${opts.uid ?? "uid-rsvp@example.test"}`, + `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"); +} + +/** A Microsoft-shaped raw MIME body carrying one `text/calendar` reply part. */ +function rsvpMime(ics: string): string { + return ( + [ + "MIME-Version: 1.0", + "From: Beth Round ", + "Subject: RSVP notification", + "Content-Type: text/calendar; method=REPLY", + "Content-Transfer-Encoding: 7bit", + ].join("\r\n") + + "\r\n\r\n" + + ics + ); +} + +/** A Graph message flagged by the classifyOutlookCalendar pre-filter as an RSVP. */ +function rsvpMessage( + id: string, + conversationId: string, + meetingMessageType: + | "meetingAccepted" + | "meetingDeclined" + | "meetingTentativelyAccepted", + uid: string +): GraphMessage { + return { + id, + conversationId, + internetMessageId: `<${id}>`, + subject: "RSVP notification", + from: { emailAddress: { name: "Beth Round", address: "beth@example.test" } }, + toRecipients: [{ emailAddress: { address: "organizer@example.test" } }], + ccRecipients: [], + receivedDateTime: "2026-08-04T14:00:00.000Z", + isRead: true, + isDraft: false, + body: { contentType: "text", content: "Beth Round has responded." }, + bodyPreview: "Beth Round has responded.", + meetingMessageType, + event: { iCalUId: uid }, + } as GraphMessage; +} + +/** An ordinary (non-RSVP) human reply in the same conversation. */ +function plainReplyMessage(id: string, conversationId: string): GraphMessage { + return { + id, + conversationId, + internetMessageId: `<${id}>`, + subject: "Re: RSVP notification", + from: { emailAddress: { name: "Beth Round", address: "beth@example.test" } }, + toRecipients: [{ emailAddress: { address: "organizer@example.test" } }], + ccRecipients: [], + receivedDateTime: "2026-08-04T14:05:00.000Z", + isRead: true, + isDraft: false, + body: { contentType: "text", content: "No problem, let's find another time." }, + bodyPreview: "No problem, let's find another time.", + } as GraphMessage; +} + +describe("processConversationsFn — attendee responses fold onto the event", () => { + let mimeById: Map; + + beforeEach(() => { + mimeById = new Map(); + graphApi.getMimeContent.mockImplementation( + async (id: string) => mimeById.get(id) ?? null + ); + }); + + it("writes a note to the event thread and saves no email link", async () => { + const { host } = makeFoldHost(); + const { notes, links } = captureSaves(host); + const uid = "uid-rsvp@example.test"; + const msg = rsvpMessage("msg-decline", "conv-decline", "meetingDeclined", uid); + mimeById.set("msg-decline", rsvpMime(replyIcs("DECLINED", { uid }))); + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ + thread: { source: `icaluid:${uid}` }, + key: "", + content: "Beth Round declined.", + contentType: "markdown", + created: new Date("2026-08-04T14:00:00.000Z"), + unread: true, + author: { email: "beth@example.test", name: "Beth Round" }, + deferUntilThread: true, + }); + expect(links).toHaveLength(0); + }); + + it("carries the responder's personal note into the quote", async () => { + const { host } = makeFoldHost(); + const { notes } = captureSaves(host); + const uid = "uid-rsvp-comment@example.test"; + const msg = rsvpMessage("msg-decline-comment", "conv-decline-comment", "meetingDeclined", uid); + mimeById.set( + "msg-decline-comment", + rsvpMime(replyIcs("DECLINED", { uid, comment: "Could we move this to Thursday?" })) + ); + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + expect(notes[0].content).toBe( + "Beth Round declined.\n\n> Could we move this to Thursday?" + ); + }); + + it("writes no note at all for a bare acceptance, and saves no email link", async () => { + const { host } = makeFoldHost(); + const { notes, links } = captureSaves(host); + const uid = "uid-rsvp-accept@example.test"; + const msg = rsvpMessage("msg-accept", "conv-accept", "meetingAccepted", uid); + mimeById.set("msg-accept", rsvpMime(replyIcs("ACCEPTED", { uid }))); + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + // 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 — and the responses-only conversation creates no email thread. + expect(notes).toHaveLength(0); + expect(links).toHaveLength(0); + }); + + it("writes a note for an acceptance carrying a personal comment", async () => { + const { host } = makeFoldHost(); + const { notes } = captureSaves(host); + const uid = "uid-rsvp-accept-comment@example.test"; + const msg = rsvpMessage("msg-accept-comment", "conv-accept-comment", "meetingAccepted", uid); + mimeById.set( + "msg-accept-comment", + rsvpMime(replyIcs("ACCEPTED", { uid, comment: "Sounds good, I'll bring the deck" })) + ); + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + 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, map } = makeFoldHost(); + const { notes } = captureSaves(host); + const uid = "uid-rsvp-reversal@example.test"; + + const declineMsg = rsvpMessage("msg-reversal-1", "conv-reversal-1", "meetingDeclined", uid); + mimeById.set("msg-reversal-1", rsvpMime(replyIcs("DECLINED", { uid }))); + await processConversationsFn( + host, + [{ messages: [declineMsg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + const key = priorRsvpKey(uid, "beth@example.test", null); + expect(map.get(key)).toBe("DECLINED"); + + const acceptMsg = rsvpMessage("msg-reversal-2", "conv-reversal-2", "meetingAccepted", uid); + mimeById.set("msg-reversal-2", rsvpMime(replyIcs("ACCEPTED", { uid }))); + await processConversationsFn( + host, + [{ messages: [acceptMsg], attachmentsByMessageId: new Map(), parentHeaders: null }], + 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(map.has(key)).toBe(false); + }); + + it("does not let a decline on one occurrence suppress an acceptance on another", async () => { + const { host, map } = makeFoldHost(); + const { notes } = captureSaves(host); + const uid = "uid-rsvp-recurring@example.test"; + + // Beth declines the Aug 4 occurrence of a recurring standup. + const aug4Decline = rsvpMessage("msg-aug4", "conv-aug4", "meetingDeclined", uid); + mimeById.set( + "msg-aug4", + rsvpMime(replyIcs("DECLINED", { uid, recurrenceId: "20260804T140000Z" })) + ); + await processConversationsFn( + host, + [{ messages: [aug4Decline], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + expect(notes).toHaveLength(1); + const aug4Key = priorRsvpKey( + uid, + "beth@example.test", + new Date("2026-08-04T14:00:00Z") + ); + expect(map.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. + const aug18Accept = rsvpMessage("msg-aug18", "conv-aug18", "meetingAccepted", uid); + mimeById.set( + "msg-aug18", + rsvpMime(replyIcs("ACCEPTED", { uid, recurrenceId: "20260818T140000Z" })) + ); + await processConversationsFn( + host, + [{ messages: [aug18Accept], attachmentsByMessageId: new Map(), parentHeaders: null }], + 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(map.get(aug4Key)).toBe("DECLINED"); + }); + + it("marks a folded response read during the initial backfill", async () => { + const { host } = makeFoldHost(); + const { notes } = captureSaves(host); + const uid = "uid-rsvp-initial@example.test"; + const msg = rsvpMessage("msg-initial", "conv-initial", "meetingDeclined", uid); + mimeById.set("msg-initial", rsvpMime(replyIcs("DECLINED", { uid }))); + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + true, + "inbox" + ); + + // 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("passes deferUntilThread so the platform holds a miss instead of us retrying it", async () => { + const { host } = makeFoldHost(); + const { notes } = captureSaves(host); + const uid = "uid-rsvp-defer@example.test"; + const msg = rsvpMessage("msg-defer", "conv-defer", "meetingDeclined", uid); + mimeById.set("msg-defer", rsvpMime(replyIcs("DECLINED", { uid }))); + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + expect(notes[0]).toMatchObject({ deferUntilThread: true }); + }); + + it("drops the email thread on a miss too, and still records the outcome", async () => { + const { host, map } = makeFoldHost(); + // null = no thread carries `icaluid:` yet (calendar hasn't synced). + // The platform parks the deferUntilThread note rather than returning it + // to us, so the message is folded away exactly as a successful fold + // would be, and no standalone email thread is created for it. + const { notes, links } = captureSaves(host, { noteId: null }); + const uid = "uid-rsvp-orphan@example.test"; + const msg = rsvpMessage("msg-orphan", "conv-orphan", "meetingDeclined", uid); + mimeById.set("msg-orphan", rsvpMime(replyIcs("DECLINED", { uid }))); + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ deferUntilThread: true }); + expect(links).toHaveLength(0); + // Recorded regardless of saveNote's return value — a deferred note + // returns no id, and gating on it would leave this decline's reversal + // state unrecorded forever. + const key = priorRsvpKey(uid, "beth@example.test", null); + expect(map.get(key)).toBe("DECLINED"); + }); + + it("leaves the message as ordinary mail when the raw MIME carries no parseable reply", async () => { + const { host } = makeFoldHost(); + const { notes, links } = captureSaves(host); + const uid = "uid-rsvp-nomime@example.test"; + // The pre-filter flags this message (meetingAccepted + iCalUId), but the + // raw MIME carries no calendar part at all — extractOutlookReply returns + // null. Without the ICS there is no comment and no occurrence, so folding + // would mis-scope the dedup key; the safe direction is ordinary mail. + const msg = rsvpMessage("msg-nomime", "conv-nomime", "meetingAccepted", uid); + mimeById.set( + "msg-nomime", + [ + "MIME-Version: 1.0", + "From: Beth Round ", + "Content-Type: text/plain", + "", + "Beth Round has responded.", + ].join("\r\n") + ); + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + expect(notes).toHaveLength(0); + expect(links).toHaveLength(1); + const keys = (links[0].notes ?? []).map((n) => (n as { key?: string }).key); + expect(keys).toEqual([""]); + }); + + it("leaves the message as ordinary mail when the MIME fetch itself misses (e.g. 404)", async () => { + const { host } = makeFoldHost(); + const { notes, links } = captureSaves(host); + const uid = "uid-rsvp-no-mime-fetch@example.test"; + const msg = rsvpMessage("msg-no-mime-fetch", "conv-no-mime-fetch", "meetingDeclined", uid); + // mimeById has no entry — getMimeContent resolves null (404). + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + expect(notes).toHaveLength(0); + expect(links).toHaveLength(1); + }); + + it("keeps ordinary correspondence in its own email thread, dropping only the folded RSVP message", async () => { + const { host } = makeFoldHost(); + const { notes, links } = captureSaves(host); + const uid = "uid-rsvp-mixed@example.test"; + const rsvp = rsvpMessage("msg-mixed-1", "conv-mixed", "meetingDeclined", uid); + const reply = plainReplyMessage("msg-mixed-2", "conv-mixed"); + mimeById.set("msg-mixed-1", rsvpMime(replyIcs("DECLINED", { uid }))); + + await processConversationsFn( + host, + [{ messages: [rsvp, reply], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + expect(notes).toHaveLength(1); // the folded RSVP note on the event thread + expect(links).toHaveLength(1); // the conversation, minus the folded message + const keys = (links[0].notes ?? []).map((n) => (n as { key?: string }).key); + expect(keys).toEqual([""]); + }); +}); diff --git a/connectors/outlook/src/mail/sync.ts b/connectors/outlook/src/mail/sync.ts index 6879f03f..cd3c1960 100644 --- a/connectors/outlook/src/mail/sync.ts +++ b/connectors/outlook/src/mail/sync.ts @@ -31,21 +31,30 @@ import type { Actor, ActorId, NewLinkWithNotes, + NewNote, Note, Thread, } from "@plotday/twister/plot"; import type { WebhookRequest } from "@plotday/twister/tools/network"; import { markdownToHtml } from "@plotday/twister/utils/markdown-html"; +import { + composeRsvpNote, + priorRsvpKey, + shouldEmitRsvpNote, + type RsvpReply, +} from "@plotday/rsvp-fold"; import { enrichLinkContactsFromOutlook } from "./enrich"; import { EXCLUDED_WELL_KNOWN, GraphMailApi, GraphMailApiError, + RSVP_PARTSTAT, classifyOutlookCalendar, conversationSource, isConversationFlagged, isConversationUnread, + messageDate, recipientEmails, sortConversation, transformOutlookConversation, @@ -54,6 +63,7 @@ import { type GraphMessage, type WellKnownFolders, } from "./graph-mail-api"; +import { extractOutlookReply } from "./outlook-ics-reply"; import { outlookSignals } from "./outlook-facets"; // --------------------------------------------------------------------------- @@ -210,6 +220,12 @@ export interface OutlookMailSyncHost { ): Promise<{ token: string; scopes: string[] } | null>; /** Persist a link (upsert by source). Returns the saved thread id (or null if filtered). */ saveLink(link: NewLinkWithNotes): Promise; + /** + * Attach a note to an EXISTING thread addressed by `{ source }` or + * `{ id }`. Creates no thread-level link. Returns the note id, or null + * when the target thread could not be resolved. + */ + saveNote(note: NewNote): Promise; /** Signal that the initial backfill for a channel has finished. */ channelSyncCompleted(channelId: string): Promise; /** Set a thread's to-do (flagged) state from the connector's own write. */ @@ -1359,6 +1375,55 @@ export async function drainNotifiedMessagesFn( return retry.length > 0 ? { retry } : undefined; } +/** + * 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. Mirrors the Google + * connector's `recordRsvpOutcome`. + */ +async function recordRsvpOutcome( + host: OutlookMailSyncHost, + key: string, + partstat: RsvpReply["partstat"] +): Promise { + if (partstat === "ACCEPTED") { + await host.clear(key); + return; + } + await host.set(key, partstat); +} + +/** + * Reads the raw MIME for every message the {@link RSVP_PARTSTAT} pre-filter + * (Graph's own `meetingMessageType`, which requires no extra request) flags + * as a candidate meeting response, across the whole batch. Resolving an API + * client is itself a token round-trip, so a batch with no candidate at all + * skips straight to an empty map. A batch whose connection has no usable + * token degrades the same way: those conversations sync as plain email. + */ +async function resolveBatchRsvpMimeFn( + host: OutlookMailSyncHost, + messages: GraphMessage[] +): Promise> { + const candidates = messages.filter( + (m) => + !m.isDraft && + m.event?.iCalUId && + m.meetingMessageType && + RSVP_PARTSTAT[m.meetingMessageType] + ); + if (candidates.length === 0) return new Map(); + const api = await getApiAnyFn(host); + if (!api) return new Map(); + + const mimeById = new Map(); + for (const m of candidates) { + const mime = await api.getMimeContent(m.id); + if (mime) mimeById.set(m.id, mime); + } + return mimeById; +} + export async function processConversationsFn( host: OutlookMailSyncHost, items: ConversationItem[], @@ -1421,6 +1486,13 @@ export async function processConversationsFn( console.warn("Failed to enrich Outlook contacts (non-blocking):", err); } + // Raw MIME for every RSVP-flagged message in the batch, resolved once + // ahead of the save fan-out (mirrors Google's `icsByMessage`). + const mimeByMessageId = await resolveBatchRsvpMimeFn( + host, + transformed.flatMap(({ item }) => item.messages) + ); + for (const { item, plot: plotThread, @@ -1450,6 +1522,90 @@ export async function processConversationsFn( filtered.push(note); } plotThread.notes = filtered; + + // Attendee responses ("Declined: ") belong on the event, not in + // a thread of their own. Fold each RSVP-flagged message onto the + // event's thread and drop its note here — a conversation that was + // nothing but responses is left with no notes and falls out at the + // guard below, so no email link is ever created for it. A conversation + // that also carries real correspondence keeps its thread, minus the + // folded messages. + const foldedMessageIds = new Set(); + for (const m of item.messages) { + if (m.isDraft) continue; + const uid = m.event?.iCalUId; + if (!uid) continue; + const partstat = m.meetingMessageType + ? RSVP_PARTSTAT[m.meetingMessageType] + : undefined; + if (!partstat) continue; // Pre-filter: not a candidate meeting response. + + // Without the raw MIME there is no ICS, so no comment and no + // occurrence — folding on the Graph metadata alone would mis-scope + // the dedup key. Leave the message as ordinary mail instead; this + // covers both a missing/404'd fetch and MIME that carries no + // parseable calendar part. + const mime = mimeByMessageId.get(m.id); + if (!mime) continue; + const reply = extractOutlookReply(mime, { + name: m.from?.emailAddress?.name ?? null, + email: m.from?.emailAddress?.address ?? "", + }); + if (!reply) continue; + + const noteKey = m.internetMessageId ?? m.id; + const priorKey = priorRsvpKey(uid, reply.attendeeEmail, reply.occurrence); + // Only a bare acceptance consults prior state; every other response + // emits regardless, so skip the store round-trip. + 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 a responses-only conversation + // from becoming an email thread of its own. + if (!shouldEmitRsvpNote(reply, hadPriorNonAccept)) { + foldedMessageIds.add(noteKey); + continue; + } + + // saveNote returns null when no thread carries `icaluid:` yet + // (the calendar event hasn't synced). deferUntilThread has the + // platform hold the note and attach it once that thread appears. + await host.tools.integrations.saveNote({ + thread: { source: `icaluid:${uid}` }, + key: noteKey, + content: composeRsvpNote(reply), + contentType: "markdown", + created: messageDate(m), + author: { + email: reply.attendeeEmail, + ...(reply.attendeeName ? { name: reply.attendeeName } : {}), + }, + // 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, + deferUntilThread: true, + }); + foldedMessageIds.add(noteKey); + // Recorded regardless of the saveNote return value: a deferred note + // returns no id, and gating on it would leave a deferred + // non-acceptance unrecorded forever, wrongly treating a later bare + // acceptance as reversing nothing. + await recordRsvpOutcome(host, priorKey, reply.partstat); + } + 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 (plotThread.notes.length === 0) continue; const isUnread = isConversationUnread(item.messages); if (initialSync) { diff --git a/libs/rsvp-fold/src/rsvp-note.ts b/libs/rsvp-fold/src/rsvp-note.ts index 502a8fde..8df58f62 100644 --- a/libs/rsvp-fold/src/rsvp-note.ts +++ b/libs/rsvp-fold/src/rsvp-note.ts @@ -1,10 +1,11 @@ /** * Presentation for attendee responses folded onto a calendar event's thread. * - * Google's own notification email states the response in one sentence and then - * repeats the entire event — Meet dial-in, When, Location, Guests — all of - * which the event thread already shows. Only the response itself and the - * responder's personal note are new, so that is all these notes carry. + * A calendar system's own notification email states the response in one + * sentence and then repeats the entire event — dial-in, When, Location, + * Guests — all of which the event thread already shows. Only the response + * itself and the responder's personal note are new, so that is all these + * notes carry. */ /** @@ -33,8 +34,8 @@ const VERBS: Record = { }; /** - * Format an occurrence date the same way the cancellation note does - * (`calendar/sync.ts`), so the two annotations on a recurring series read + * Format an occurrence date the same way a cancellation note on the same + * event thread does, so the two annotations on a recurring series read * alike. All-day occurrences are pinned to UTC because their instant is a * bare date; timed ones use the worker's zone, which is UTC — a late-evening * local occurrence can therefore format as the following day, exactly as the From e9692e5475ae6173bec68d4d6de0cbde3313dcc4 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 12:37:31 -0400 Subject: [PATCH 09/14] Fix round 1: guard batch MIME fetch and exclude folded messages from bundling/facets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveBatchRsvpMimeFn now catches a per-message Graph failure (5xx, a mid-batch 401, a retry-exhausted 429/503) instead of letting it escape the batch — one bad message now degrades to ordinary mail instead of aborting every conversation in the pass. The fold's surviving-message set is now also used for classifyOutlookCalendar and the facet/preview "parent" pick, so a folded RSVP message can no longer (a) get the rest of a mixed conversation bundled onto the calendar event's thread via a shared `sources` element, or (b) supply a stale `signals.noteKey` or thread preview pointing at a note that no longer exists. --- connectors/outlook/src/mail/sync.test.ts | 90 ++++++++++++++++++++++++ connectors/outlook/src/mail/sync.ts | 75 ++++++++++++++++++-- 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/connectors/outlook/src/mail/sync.test.ts b/connectors/outlook/src/mail/sync.test.ts index 431e60cd..05a0a21d 100644 --- a/connectors/outlook/src/mail/sync.test.ts +++ b/connectors/outlook/src/mail/sync.test.ts @@ -717,5 +717,95 @@ describe("processConversationsFn — attendee responses fold onto the event", () expect(links).toHaveLength(1); // the conversation, minus the folded message const keys = (links[0].notes ?? []).map((n) => (n as { key?: string }).key); expect(keys).toEqual([""]); + + // The folded RSVP was the only calendar-classifiable message in this + // conversation — its own thread must not get bundled onto the event's + // thread via a shared `sources` element (that would merge the surviving + // human reply onto the calendar event thread too). + expect(links[0].sources ?? []).not.toContain(`icaluid:${uid}`); + + // Signals/noteKey must point at a note that still exists — the RSVP + // message (14:00) sorts before the reply (14:05), so an unfiltered + // "parent" pick would select the folded message instead. + expect(links[0].signals?.noteKey).toBe(""); + expect(keys).toContain(links[0].signals?.noteKey); + + // The preview must come from the surviving reply, not the folded RSVP + // notification (whose bodyPreview seeded transformOutlookConversation's + // original preview since it sorted first). + expect(links[0].preview).toBe("No problem, let's find another time."); + }); + + it("degrades a single candidate's failed MIME fetch to ordinary mail without aborting the rest of the batch", async () => { + const { host } = makeFoldHost(); + const { notes, links } = captureSaves(host); + const goodUid = "uid-rsvp-batch-good@example.test"; + const badUid = "uid-rsvp-batch-bad@example.test"; + const badMsg = rsvpMessage("msg-batch-bad", "conv-batch-bad", "meetingDeclined", badUid); + const goodMsg = rsvpMessage("msg-batch-good", "conv-batch-good", "meetingDeclined", goodUid); + + graphApi.getMimeContent.mockImplementation(async (id: string) => { + if (id === "msg-batch-bad") throw new Error("500 from Graph"); + if (id === "msg-batch-good") { + return rsvpMime(replyIcs("DECLINED", { uid: goodUid })); + } + return null; + }); + + await processConversationsFn( + host, + [ + { messages: [badMsg], attachmentsByMessageId: new Map(), parentHeaders: null }, + { messages: [goodMsg], attachmentsByMessageId: new Map(), parentHeaders: null }, + ], + false, + "inbox" + ); + + // The failing fetch degrades ITS OWN message to ordinary mail rather + // than throwing out of the batch, so its conversation still gets a + // (non-folded) email thread. + expect(links).toHaveLength(1); + const badLinkKeys = (links[0].notes ?? []).map((n) => (n as { key?: string }).key); + expect(badLinkKeys).toEqual([""]); + + // The other conversation in the same batch still folds normally — a + // single Graph error must not fail every conversation in the batch. + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ thread: { source: `icaluid:${goodUid}` } }); + }); + + it("does not read prior state for a decline, a tentative, or a commented acceptance", async () => { + const { host } = makeFoldHost(); + captureSaves(host); + const getSpy = host.get as ReturnType; + + const shapes: Array<{ + partstat: "DECLINED" | "TENTATIVE" | "ACCEPTED"; + meetingMessageType: "meetingDeclined" | "meetingTentativelyAccepted" | "meetingAccepted"; + comment?: string; + }> = [ + { partstat: "DECLINED", meetingMessageType: "meetingDeclined" }, + { partstat: "TENTATIVE", meetingMessageType: "meetingTentativelyAccepted" }, + { partstat: "ACCEPTED", meetingMessageType: "meetingAccepted", comment: "Sounds good" }, + ]; + + for (const shape of shapes) { + getSpy.mockClear(); + const uid = `uid-no-prior-read-${shape.partstat}@example.test`; + const msgId = `msg-no-prior-read-${shape.partstat}`; + const msg = rsvpMessage(msgId, `conv-no-prior-read-${shape.partstat}`, shape.meetingMessageType, uid); + mimeById.set(msgId, rsvpMime(replyIcs(shape.partstat, { uid, comment: shape.comment }))); + + await processConversationsFn( + host, + [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + const priorKey = priorRsvpKey(uid, "beth@example.test", null); + expect(getSpy).not.toHaveBeenCalledWith(priorKey); + } }); }); diff --git a/connectors/outlook/src/mail/sync.ts b/connectors/outlook/src/mail/sync.ts index cd3c1960..865a013e 100644 --- a/connectors/outlook/src/mail/sync.ts +++ b/connectors/outlook/src/mail/sync.ts @@ -1418,8 +1418,21 @@ async function resolveBatchRsvpMimeFn( const mimeById = new Map(); for (const m of candidates) { - const mime = await api.getMimeContent(m.id); - if (mime) mimeById.set(m.id, mime); + try { + const mime = await api.getMimeContent(m.id); + if (mime) mimeById.set(m.id, mime); + } catch (error) { + // getMimeContent returns null (not a throw) on 404; anything else + // (5xx, a mid-batch 401, a 429/503 that still fails after call()'s own + // retry) throws GraphMailApiError. That must not abort every other + // conversation in this batch — degrade to "no MIME for this message" + // exactly like a 404 does. The fold loop already treats a missing + // entry here as "leave as ordinary mail". + console.warn( + `[outlook-mail] getMimeContent failed for ${m.id}, leaving as ordinary mail:`, + error + ); + } } return mimeById; } @@ -1599,11 +1612,52 @@ export async function processConversationsFn( // acceptance as reversing nothing. await recordRsvpOutcome(host, priorKey, reply.partstat); } + // Messages whose note survived the fold — everything below that reads + // `item.messages` to pick a "parent" (facets, calendar bundling) must + // use this instead, or it can select a message whose note no longer + // exists in `plotThread.notes`. + const survivingMessages = + foldedMessageIds.size > 0 + ? item.messages.filter( + (m) => !foldedMessageIds.has(m.internetMessageId ?? m.id) + ) + : item.messages; + 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 the conversation's first non-draft message's + // bodyPreview in transformOutlookConversation) 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 originalParent = sortConversation(item.messages).find( + (m) => !m.isDraft + ); + const originalParentKey = originalParent + ? (originalParent.internetMessageId ?? originalParent.id) + : null; + if (originalParentKey && foldedMessageIds.has(originalParentKey)) { + const firstSurvivingNote = plotThread.notes[0]; + const firstSurvivingKey = + firstSurvivingNote && "key" in firstSurvivingNote + ? (firstSurvivingNote as { key: string }).key + : null; + const firstSurvivingMessage = firstSurvivingKey + ? item.messages.find( + (m) => (m.internetMessageId ?? m.id) === firstSurvivingKey + ) + : null; + plotThread.preview = + firstSurvivingMessage?.bodyPreview || + (firstSurvivingNote as { content?: string } | undefined) + ?.content || + null; + } } if (plotThread.notes.length === 0) continue; @@ -1638,8 +1692,16 @@ export async function processConversationsFn( // Bundle onto the calendar event's thread when this conversation relates // to one (a Plot-sent reply chain, or a meeting update/cancellation). + // Uses `survivingMessages`, NOT `item.messages`: an `rsvp`-kind + // classification whose only supporting message was just folded away + // must not append `icaluid:` to `sources` — a shared `sources` + // element bundles threads together, so that would merge the rest of a + // mixed conversation onto the event thread even though the fold's own + // contract is to leave real correspondence in its own thread. A + // `cancel`/`update` classification is unaffected: those message kinds + // are never added to `foldedMessageIds`, so they're still present here. const calBundle = classifyOutlookCalendar( - item.messages, + survivingMessages, item.parentHeaders ); if (calBundle) { @@ -1654,8 +1716,11 @@ export async function processConversationsFn( } } - // Compute mail signals from the parent message's headers. - const facetParent = sortConversation(item.messages).find( + // Compute mail signals from the parent message's headers. Also uses + // `survivingMessages` — otherwise a folded RSVP notification could be + // selected as the parent, pointing `signals.noteKey` at a note that no + // longer exists in `plotThread.notes`. + const facetParent = sortConversation(survivingMessages).find( (m) => !m.isDraft ); if (facetParent) { From 9b5621e4f9e821140c93be59b1ea692f83ccf863 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 13:02:42 -0400 Subject: [PATCH 10/14] Never bundle an RSVP notification onto the event thread Three fixes from the whole-branch review: - A reply that could not be parsed (no MIME, no calendar part, unreadable ICS) was still bundled onto the event's thread via its icaluid source, putting the notification email itself in front of the organiser. The rsvp classification is a pre-filter only and never contributes a source now; cancel and update still bundle as before. - Base64 calendar parts were decoded as Latin-1, so accented and non-Latin names and comments arrived mangled. They are decoded as UTF-8 now, matching the other connector. - A folded reply still drove the surviving conversation's unread and flagged state, leaving a thread marked unread with nothing unread left in it. Also corrects the NewNote.unread documentation: omitting it does not leave read state alone, and a connector that needs a note to avoid creating unread must pass an explicit false. --- .../src/mail/outlook-ics-reply.test.ts | 63 +++++++++++++++++-- .../outlook/src/mail/outlook-ics-reply.ts | 13 +++- connectors/outlook/src/mail/sync.test.ts | 44 +++++++++++++ connectors/outlook/src/mail/sync.ts | 17 ++++- twister/src/plot.ts | 13 ++-- 5 files changed, 136 insertions(+), 14 deletions(-) diff --git a/connectors/outlook/src/mail/outlook-ics-reply.test.ts b/connectors/outlook/src/mail/outlook-ics-reply.test.ts index 4b3fe995..a911efdb 100644 --- a/connectors/outlook/src/mail/outlook-ics-reply.test.ts +++ b/connectors/outlook/src/mail/outlook-ics-reply.test.ts @@ -91,6 +91,30 @@ const MS_ICS_ACCEPTED = [ "END:VCALENDAR", ].join(CRLF); +/** + * A synthetic Microsoft-shaped reply whose `CN` (display name) and `COMMENT` + * (free-text note) both carry non-ASCII characters — routine for real + * attendees and comments, but absent from every other fixture in this file, + * all of which are plain ASCII. + */ +const MS_ICS_ACCEPTED_UNICODE = [ + "BEGIN:VCALENDAR", + "METHOD:REPLY", + "PRODID:Microsoft Exchange Server 2010", + "VERSION:2.0", + "BEGIN:VEVENT", + "ATTENDEE;PARTSTAT=ACCEPTED;CN=José Müller:mailto:jose@example.test", + "COMMENT;LANGUAGE=en-US:Café résumé 会議 🎉", + "UID:2sfjkg3asr2hofgcgfsi51ks84@google.com", + "SUMMARY;LANGUAGE=en-US:Accepted: Hey outlook", + "DTSTART;TZID=Eastern Standard Time:20260803T113000", + "DTEND;TZID=Eastern Standard Time:20260803T120000", + "SEQUENCE:0", + "X-MICROSOFT-CDO-ALLDAYEVENT:FALSE", + "END:VEVENT", + "END:VCALENDAR", +].join(CRLF); + /** * A synthetic, differently-shaped reply — not a real capture — used only to * prove the duplicate-attachment precedence test actually exercises @@ -113,6 +137,19 @@ function b64(text: string): string { return btoa(text); } +/** + * Base64-encode text as UTF-8 bytes, the way a real mail client encodes a + * non-ASCII `base64` part — plain `btoa` throws on any character outside + * Latin-1, so this is the only way to build a fixture with an accented name, + * CJK, or emoji in it. + */ +function b64Utf8(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + /** One MIME part: headers + a blank line + body, CRLF throughout. */ function mimePart(headers: string[], body: string): string { return headers.join(CRLF) + CRLF + CRLF + body; @@ -121,7 +158,7 @@ function mimePart(headers: string[], body: string): string { /** * A `multipart/mixed` Google-shaped message: a `multipart/alternative` * (text/plain, text/html, text/calendar) plus a sibling `application/ics` - * attachment — the real captured shape (see fixtures README). + * attachment — the real captured shape. */ function googleShapedMessage(opts: { calendarEncoding: "7bit" | "base64"; @@ -195,9 +232,14 @@ function googleShapedMessage(opts: { /** * A `multipart/alternative` Microsoft-shaped message — text/plain, * text/html, text/calendar (base64) — and critically NO attachment part at - * all (per the fixtures README, `X-MS-Has-Attach` is empty for these). + * all: real Microsoft-generated meeting responses carry an empty + * `X-MS-Has-Attach` header, unlike Google's duplicate `application/ics` + * attachment. */ -function microsoftShapedMessage(icsBody: string): string { +function microsoftShapedMessage( + icsBody: string, + encodeCalendar: (text: string) => string = b64 +): string { const boundary = "ms_boundary_0003"; const plainPart = mimePart( ['Content-Type: text/plain; charset="us-ascii"', "Content-Transfer-Encoding: base64"], @@ -208,8 +250,8 @@ function microsoftShapedMessage(icsBody: string): string { "

Ana Ruiz has accepted this invitation.

" ); const calendarPart = mimePart( - ['Content-Type: text/calendar; method=REPLY; charset="us-ascii"', "Content-Transfer-Encoding: base64"], - b64(icsBody) + ['Content-Type: text/calendar; method=REPLY; charset="utf-8"', "Content-Transfer-Encoding: base64"], + encodeCalendar(icsBody) ); const body = @@ -319,6 +361,17 @@ describe("extractOutlookReply", () => { }); }); + it("decodes a base64 text/calendar part with non-ASCII text intact (accents, CJK, emoji)", () => { + const mime = microsoftShapedMessage(MS_ICS_ACCEPTED_UNICODE, b64Utf8); + const reply = extractOutlookReply(mime, FALLBACK); + expect(reply).toMatchObject({ + partstat: "ACCEPTED", + attendeeName: "José Müller", + attendeeEmail: "jose@example.test", + comment: "Café résumé 会議 🎉", + }); + }); + it("returns null when the message carries no calendar part", () => { const mime = plainMessage(); expect(extractOutlookReply(mime, FALLBACK)).toBeNull(); diff --git a/connectors/outlook/src/mail/outlook-ics-reply.ts b/connectors/outlook/src/mail/outlook-ics-reply.ts index 0d1455fe..bef55c8d 100644 --- a/connectors/outlook/src/mail/outlook-ics-reply.ts +++ b/connectors/outlook/src/mail/outlook-ics-reply.ts @@ -74,10 +74,21 @@ function collectParts(raw: string): MimePart[] { ]; } +/** Decode a base64 string as UTF-8 text, not `atob`'s raw Latin-1 bytes — `COMMENT` is + * user-typed free text and `CN` is a display name, so accents, CJK, and emoji are routine. */ +function decodeBase64Utf8(data: string): string { + const binaryString = atob(data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return new TextDecoder("utf-8").decode(bytes); +} + /** Decode a part's body per its Content-Transfer-Encoding — only the two encodings this reply shape uses. */ function decodeBody(part: MimePart): string { if (part.transferEncoding.toLowerCase() === "base64") { - return atob(part.body.replace(/[\r\n]/g, "")); + return decodeBase64Utf8(part.body.replace(/[\r\n]/g, "")); } return part.body; } diff --git a/connectors/outlook/src/mail/sync.test.ts b/connectors/outlook/src/mail/sync.test.ts index 05a0a21d..5f0de32d 100644 --- a/connectors/outlook/src/mail/sync.test.ts +++ b/connectors/outlook/src/mail/sync.test.ts @@ -678,6 +678,15 @@ describe("processConversationsFn — attendee responses fold onto the event", () expect(links).toHaveLength(1); const keys = (links[0].notes ?? []).map((n) => (n as { key?: string }).key); expect(keys).toEqual([""]); + + // The pre-filter still saw a `meetingAccepted` + iCalUId on this message, + // so `classifyOutlookCalendar` would classify it as `kind: "rsvp"` even + // though the fold itself failed. That classification must never append + // `icaluid:` to `sources` — otherwise this ordinary-mail thread (and + // any real correspondence sharing its conversation) gets bundled onto the + // calendar event's thread, marking the organiser unread for a plain + // notification email. + expect(links[0].sources ?? []).not.toContain(`icaluid:${uid}`); }); it("leaves the message as ordinary mail when the MIME fetch itself misses (e.g. 404)", async () => { @@ -696,6 +705,9 @@ describe("processConversationsFn — attendee responses fold onto the event", () expect(notes).toHaveLength(0); expect(links).toHaveLength(1); + // Same reasoning as the no-parseable-MIME case above: a 404'd fetch still + // leaves an `rsvp`-classifiable message behind, and it must not bundle. + expect(links[0].sources ?? []).not.toContain(`icaluid:${uid}`); }); it("keeps ordinary correspondence in its own email thread, dropping only the folded RSVP message", async () => { @@ -736,6 +748,38 @@ describe("processConversationsFn — attendee responses fold onto the event", () expect(links[0].preview).toBe("No problem, let's find another time."); }); + it("does not mark the surviving thread unread from a folded RSVP notification's own unread state", async () => { + const { host } = makeFoldHost(); + const { notes, links } = captureSaves(host); + const uid = "uid-rsvp-unread-fold@example.test"; + // The RSVP notification is unread in Outlook, but its note gets folded + // away. The surviving reply is already read. + const rsvp = { + ...rsvpMessage("msg-unread-fold-1", "conv-unread-fold", "meetingDeclined", uid), + isRead: false, + }; + const reply = { + ...plainReplyMessage("msg-unread-fold-2", "conv-unread-fold"), + isRead: true, + }; + mimeById.set("msg-unread-fold-1", rsvpMime(replyIcs("DECLINED", { uid }))); + + await processConversationsFn( + host, + [{ messages: [rsvp, reply], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + + expect(notes).toHaveLength(1); // the folded RSVP note on the event thread + expect(links).toHaveLength(1); + // Computed from `survivingMessages` (just the read reply), not + // `item.messages` (which still has the unread RSVP) — otherwise the + // surviving thread would be marked unread with nothing in it the user + // can read to clear it. + expect(links[0].unread).toBe(false); + }); + it("degrades a single candidate's failed MIME fetch to ordinary mail without aborting the rest of the batch", async () => { const { host } = makeFoldHost(); const { notes, links } = captureSaves(host); diff --git a/connectors/outlook/src/mail/sync.ts b/connectors/outlook/src/mail/sync.ts index 865a013e..d1b20c6f 100644 --- a/connectors/outlook/src/mail/sync.ts +++ b/connectors/outlook/src/mail/sync.ts @@ -1661,7 +1661,11 @@ export async function processConversationsFn( } if (plotThread.notes.length === 0) continue; - const isUnread = isConversationUnread(item.messages); + // Uses `survivingMessages`, NOT `item.messages`: an unread or flagged + // RSVP notification that was folded away must not drive the surviving + // thread's unread/to-do state — otherwise the thread shows unread (or + // becomes a to-do) with nothing in it the user can act on to clear it. + const isUnread = isConversationUnread(survivingMessages); if (initialSync) { plotThread.unread = isUnread; plotThread.archived = false; @@ -1704,7 +1708,14 @@ export async function processConversationsFn( survivingMessages, item.parentHeaders ); - if (calBundle) { + // `kind === "rsvp"` is only ever a pre-filter for the fold step above, + // never a bundling signal: when the fold succeeds the message is gone + // from `survivingMessages` and this branch already can't see it, but + // when the fold does NOT happen (no MIME, no parseable calendar part, + // an ICS that fails to parse) the RSVP notification message is still + // here and would otherwise get bundled onto the event thread as an + // ordinary note — exactly the outcome the fold exists to prevent. + if (calBundle && calBundle.kind !== "rsvp") { plotThread.sources = [ ...(plotThread.sources ?? []), `icaluid:${calBundle.uid}`, @@ -1735,7 +1746,7 @@ export async function processConversationsFn( }; } - const isFlagged = isConversationFlagged(item.messages); + const isFlagged = isConversationFlagged(survivingMessages); const savedThreadId = await host.tools.integrations.saveLink(plotThread); if (!savedThreadId) continue; // Link was filtered (e.g., older than sync history) diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 53d5d63d..4d5dea59 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -937,12 +937,15 @@ export type NewNote = Partial< /** * Whether this note should change the parent thread's read state. * - * - **omitted (default): leave read state alone.** The note surfaces the - * thread in the feed without creating unread, and without clearing - * unread that other notes already caused. This is the right default for - * low-signal annotations. + * - **omitted (default): no explicit read-state write.** Attaching a note + * still marks the thread unread for every recipient except the note's + * author — there is no "leave read state alone" outcome. Pass an + * explicit `false` if a note should NOT create unread (e.g. a + * low-signal annotation, or a response that says nothing the thread + * doesn't already show). * - `true`: mark the thread unread, except for the user who authored the - * note — they have necessarily seen it. + * note — they have necessarily seen it. Redundant with the default + * behavior above; pass it when you want to be explicit at the call site. * - `false`: mark the thread read for the connection owner. Use when the * external system reports the item as already read, so a two-way sync * converges. From f31f549cb55f0c0364f77e9b71e19d51897f8456 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 13:37:03 -0400 Subject: [PATCH 11/14] fix(rsvp-fold): stop a repeated attendee response from re-raising unread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gmail and Outlook connectors already avoided writing a duplicate note when an attendee's calendar response was processed twice, but each re-processed response still re-applied the note's unread flag — pulling the event thread back to unread for anyone who had already read it, even though nothing new was said. Both providers routinely redeliver the same message (a mail subscription firing on "updated" as well as "created", a sync replay), so this was a routine occurrence rather than a rare edge case. Add `alreadyFolded` and `isNonAcceptance` to `@plotday/rsvp-fold` so a connector can tell an incoming response that repeats what it already folded onto the event thread apart from a genuine change of response (e.g. a decline followed later by an acceptance, which still gets its own note). Both connectors now compare every incoming response against the last one they folded, and record every emitted response — not just outstanding declines/tentatives — so the comparison works for every response type, including a repeated acceptance. One accepted trade-off: an attendee who edits only their personal note without changing their response gets no updated note, since the response itself looks unchanged. Also removes an Outlook test that pinned an optimisation (skipping the prior-state read for most responses) that is no longer possible once every response needs the prior value for this comparison. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EojBeTMfriEnuDHpRfRh9x --- connectors/google/src/mail/sync.test.ts | 28 +++++++++- connectors/google/src/mail/sync.ts | 63 ++++++++++++---------- connectors/outlook/src/mail/sync.test.ts | 68 ++++++++++++------------ connectors/outlook/src/mail/sync.ts | 57 ++++++++++---------- libs/rsvp-fold/src/index.ts | 2 + libs/rsvp-fold/src/rsvp-note.test.ts | 33 +++++++++++- libs/rsvp-fold/src/rsvp-note.ts | 44 +++++++++++++-- 7 files changed, 198 insertions(+), 97 deletions(-) diff --git a/connectors/google/src/mail/sync.test.ts b/connectors/google/src/mail/sync.test.ts index 1fd780b9..5d63ed7d 100644 --- a/connectors/google/src/mail/sync.test.ts +++ b/connectors/google/src/mail/sync.test.ts @@ -1132,8 +1132,32 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", () // 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); + // The key now records the last folded response for every emitted + // response, including an acceptance — not just outstanding + // non-acceptances — so a later repeat of this exact ACCEPTED is + // recognised as already folded instead of re-emitting. + expect(store.get(key)).toBe("ACCEPTED"); + }); + + it("does not re-emit a note when the same conversation is processed again", async () => { + const { host } = makeHost(); + const { notes, links } = captureSaves(host); + const thread = rsvpThread("rsvp-reprocess", replyIcs("DECLINED")); + + await processEmailThreadsFn(host, [thread], false, "INBOX"); + expect(notes).toHaveLength(1); + + // Gmail's own history-based incremental sync can redeliver the same + // notification (a history replay, an at-least-once webhook) — this is + // the routine case, not a rare replay. + await processEmailThreadsFn(host, [thread], false, "INBOX"); + + // No second note: re-emitting one would re-apply its unread intent and + // drag the organiser's event thread back to unread for no new + // information. The message is still dropped from the mail side, though — + // no standalone email thread appears for it either time. + expect(notes).toHaveLength(1); + expect(links).toHaveLength(0); }); it("does not let a decline on one occurrence suppress an acceptance on another", async () => { diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index b9448c2e..2f026718 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -17,7 +17,13 @@ * returns a descriptor and lets the caller own the scheduling. */ import { enrichLinkContactsFromGoogle } from "@plotday/google-contacts"; -import { composeRsvpNote, priorRsvpKey, shouldEmitRsvpNote } from "@plotday/rsvp-fold"; +import { + alreadyFolded, + composeRsvpNote, + isNonAcceptance, + priorRsvpKey, + shouldEmitRsvpNote, +} from "@plotday/rsvp-fold"; import { baseEmail, canonicalizeEmail, @@ -39,7 +45,6 @@ import type { WebhookRequest } from "@plotday/twister/tools/network"; import { type AttachmentData, - type CalendarReply, GmailApi, GmailApiError, type GmailMessage, @@ -1511,23 +1516,6 @@ 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 @@ -1575,21 +1563,33 @@ async function saveTransformedThread( 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; + // Read on every response, not just a bare acceptance: `alreadyFolded` + // needs the stored value on every path, so there is no cheaper way to + // skip this round-trip anymore (there used to be one for the + // non-acceptance/commented-acceptance cases — traded away below). + const stored = await host.get(priorKey); + + // Re-processing a conversation re-runs this loop for a response + // already folded onto the event thread — Gmail history replay, a + // backfill overlap, at-least-once delivery. The note itself upserts + // by key, so re-saving it wouldn't duplicate it, but its `unread` + // intent would still be re-applied and drag the thread back to + // unread for anyone who already read it. Comparing against the + // stored partstat (not just presence) means a genuine change of + // response is never caught by this: an attendee who edits only their + // comment on an unchanged response gets no updated note, which is + // the accepted trade for not re-raising unread on every re-deliver. + if (alreadyFolded(stored, reply)) { + foldedMessageIds.add(reply.messageId); + continue; + } // 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)) { + if (!shouldEmitRsvpNote(reply, isNonAcceptance(stored))) { foldedMessageIds.add(reply.messageId); continue; } @@ -1645,7 +1645,12 @@ async function saveTransformedThread( // `noteId` here would leave a deferred non-acceptance unrecorded // forever, wrongly treating a later bare acceptance as reversing // nothing. - await recordRsvpOutcome(host, priorKey, reply.partstat); + // + // Always set, never cleared: the key now holds the last response + // actually folded, for every emitted response (including an + // acceptance) — that's what lets `alreadyFolded` recognise a repeat + // of ANY partstat, not just an outstanding non-acceptance. + await host.set(priorKey, reply.partstat); } if (foldedMessageIds.size > 0) { diff --git a/connectors/outlook/src/mail/sync.test.ts b/connectors/outlook/src/mail/sync.test.ts index 5f0de32d..1f17806a 100644 --- a/connectors/outlook/src/mail/sync.test.ts +++ b/connectors/outlook/src/mail/sync.test.ts @@ -533,8 +533,39 @@ describe("processConversationsFn — attendee responses fold onto the event", () // 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(map.has(key)).toBe(false); + // The key now records the last folded response for every emitted + // response, including an acceptance — not just outstanding + // non-acceptances — so a later repeat of this exact ACCEPTED is + // recognised as already folded instead of re-emitting. + expect(map.get(key)).toBe("ACCEPTED"); + }); + + it("does not re-emit a note when the same conversation is processed again", async () => { + const { host } = makeFoldHost(); + const { notes, links } = captureSaves(host); + const uid = "uid-rsvp-reprocess@example.test"; + const msg = rsvpMessage("msg-reprocess", "conv-reprocess", "meetingDeclined", uid); + mimeById.set("msg-reprocess", rsvpMime(replyIcs("DECLINED", { uid }))); + const conversation = { + messages: [msg], + attachmentsByMessageId: new Map(), + parentHeaders: null, + }; + + await processConversationsFn(host, [conversation], false, "inbox"); + expect(notes).toHaveLength(1); + + // Graph's subscription fires on `updated` as well as `created`, and the + // drain re-fetches the whole conversation for any notified message — + // this is the routine case, not a rare replay. + await processConversationsFn(host, [conversation], false, "inbox"); + + // No second note: re-emitting one would re-apply its unread intent and + // drag the organiser's event thread back to unread for no new + // information. The message is still dropped from the mail side, though — + // no standalone email thread appears for it either time. + expect(notes).toHaveLength(1); + expect(links).toHaveLength(0); }); it("does not let a decline on one occurrence suppress an acceptance on another", async () => { @@ -819,37 +850,4 @@ describe("processConversationsFn — attendee responses fold onto the event", () expect(notes[0]).toMatchObject({ thread: { source: `icaluid:${goodUid}` } }); }); - it("does not read prior state for a decline, a tentative, or a commented acceptance", async () => { - const { host } = makeFoldHost(); - captureSaves(host); - const getSpy = host.get as ReturnType; - - const shapes: Array<{ - partstat: "DECLINED" | "TENTATIVE" | "ACCEPTED"; - meetingMessageType: "meetingDeclined" | "meetingTentativelyAccepted" | "meetingAccepted"; - comment?: string; - }> = [ - { partstat: "DECLINED", meetingMessageType: "meetingDeclined" }, - { partstat: "TENTATIVE", meetingMessageType: "meetingTentativelyAccepted" }, - { partstat: "ACCEPTED", meetingMessageType: "meetingAccepted", comment: "Sounds good" }, - ]; - - for (const shape of shapes) { - getSpy.mockClear(); - const uid = `uid-no-prior-read-${shape.partstat}@example.test`; - const msgId = `msg-no-prior-read-${shape.partstat}`; - const msg = rsvpMessage(msgId, `conv-no-prior-read-${shape.partstat}`, shape.meetingMessageType, uid); - mimeById.set(msgId, rsvpMime(replyIcs(shape.partstat, { uid, comment: shape.comment }))); - - await processConversationsFn( - host, - [{ messages: [msg], attachmentsByMessageId: new Map(), parentHeaders: null }], - false, - "inbox" - ); - - const priorKey = priorRsvpKey(uid, "beth@example.test", null); - expect(getSpy).not.toHaveBeenCalledWith(priorKey); - } - }); }); diff --git a/connectors/outlook/src/mail/sync.ts b/connectors/outlook/src/mail/sync.ts index d1b20c6f..adfe5a09 100644 --- a/connectors/outlook/src/mail/sync.ts +++ b/connectors/outlook/src/mail/sync.ts @@ -38,10 +38,11 @@ import type { import type { WebhookRequest } from "@plotday/twister/tools/network"; import { markdownToHtml } from "@plotday/twister/utils/markdown-html"; import { + alreadyFolded, composeRsvpNote, + isNonAcceptance, priorRsvpKey, shouldEmitRsvpNote, - type RsvpReply, } from "@plotday/rsvp-fold"; import { enrichLinkContactsFromOutlook } from "./enrich"; @@ -1375,24 +1376,6 @@ export async function drainNotifiedMessagesFn( return retry.length > 0 ? { retry } : undefined; } -/** - * 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. Mirrors the Google - * connector's `recordRsvpOutcome`. - */ -async function recordRsvpOutcome( - host: OutlookMailSyncHost, - key: string, - partstat: RsvpReply["partstat"] -): Promise { - if (partstat === "ACCEPTED") { - await host.clear(key); - return; - } - await host.set(key, partstat); -} - /** * Reads the raw MIME for every message the {@link RSVP_PARTSTAT} pre-filter * (Graph's own `meetingMessageType`, which requires no extra request) flags @@ -1568,19 +1551,34 @@ export async function processConversationsFn( const noteKey = m.internetMessageId ?? m.id; const priorKey = priorRsvpKey(uid, reply.attendeeEmail, reply.occurrence); - // Only a bare acceptance consults prior state; every other response - // emits regardless, so skip the store round-trip. - const needsPriorState = reply.partstat === "ACCEPTED" && !reply.comment; - const hadPriorNonAccept = needsPriorState - ? Boolean(await host.get(priorKey)) - : false; + // Read on every response, not just a bare acceptance: `alreadyFolded` + // needs the stored value on every path, so there is no cheaper way to + // skip this round-trip anymore (there used to be one for the + // non-acceptance/commented-acceptance cases — traded away below). + const stored = await host.get(priorKey); + + // Re-processing a conversation re-runs this loop for a response + // already folded onto the event thread — Graph's subscription fires + // on `updated` as well as `created`, and the drain re-fetches the + // whole conversation for any notified message. The note itself + // upserts by key, so re-saving it wouldn't duplicate it, but its + // `unread` intent would still be re-applied and drag the thread back + // to unread for anyone who already read it. Comparing against the + // stored partstat (not just presence) means a genuine change of + // response is never caught by this: an attendee who edits only their + // comment on an unchanged response gets no updated note, which is + // the accepted trade for not re-raising unread on every re-deliver. + if (alreadyFolded(stored, reply)) { + foldedMessageIds.add(noteKey); + continue; + } // 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 a responses-only conversation // from becoming an email thread of its own. - if (!shouldEmitRsvpNote(reply, hadPriorNonAccept)) { + if (!shouldEmitRsvpNote(reply, isNonAcceptance(stored))) { foldedMessageIds.add(noteKey); continue; } @@ -1610,7 +1608,12 @@ export async function processConversationsFn( // returns no id, and gating on it would leave a deferred // non-acceptance unrecorded forever, wrongly treating a later bare // acceptance as reversing nothing. - await recordRsvpOutcome(host, priorKey, reply.partstat); + // + // Always set, never cleared: the key now holds the last response + // actually folded, for every emitted response (including an + // acceptance) — that's what lets `alreadyFolded` recognise a repeat + // of ANY partstat, not just an outstanding non-acceptance. + await host.set(priorKey, reply.partstat); } // Messages whose note survived the fold — everything below that reads // `item.messages` to pick a "parent" (facets, calendar bundling) must diff --git a/libs/rsvp-fold/src/index.ts b/libs/rsvp-fold/src/index.ts index ba3b0610..c41d412b 100644 --- a/libs/rsvp-fold/src/index.ts +++ b/libs/rsvp-fold/src/index.ts @@ -1,5 +1,7 @@ export { + alreadyFolded, composeRsvpNote, + isNonAcceptance, shouldEmitRsvpNote, priorRsvpKey, type RsvpReply, diff --git a/libs/rsvp-fold/src/rsvp-note.test.ts b/libs/rsvp-fold/src/rsvp-note.test.ts index e28905b4..ec6f95cd 100644 --- a/libs/rsvp-fold/src/rsvp-note.test.ts +++ b/libs/rsvp-fold/src/rsvp-note.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { composeRsvpNote, shouldEmitRsvpNote, priorRsvpKey } from "./rsvp-note"; +import { + alreadyFolded, + composeRsvpNote, + isNonAcceptance, + shouldEmitRsvpNote, + priorRsvpKey, +} from "./rsvp-note"; import type { RsvpReply } from "./rsvp-note"; function reply(overrides: Partial = {}): RsvpReply { @@ -107,6 +113,31 @@ describe("shouldEmitRsvpNote", () => { }); }); +describe("alreadyFolded", () => { + it("suppresses a repeat of the same response", () => { + expect(alreadyFolded("DECLINED", reply({ partstat: "DECLINED" }))).toBe(true); + expect(alreadyFolded("ACCEPTED", reply({ partstat: "ACCEPTED" }))).toBe(true); + }); + it("does not suppress a changed response", () => { + expect(alreadyFolded("DECLINED", reply({ partstat: "ACCEPTED" }))).toBe(false); + expect(alreadyFolded("ACCEPTED", reply({ partstat: "DECLINED" }))).toBe(false); + }); + it("does not suppress when nothing was ever folded", () => { + expect(alreadyFolded(null, reply({ partstat: "DECLINED" }))).toBe(false); + expect(alreadyFolded(undefined, reply({ partstat: "ACCEPTED" }))).toBe(false); + }); +}); + +describe("isNonAcceptance", () => { + it("is true only for a decline or a tentative", () => { + expect(isNonAcceptance("DECLINED")).toBe(true); + expect(isNonAcceptance("TENTATIVE")).toBe(true); + expect(isNonAcceptance("ACCEPTED")).toBe(false); + expect(isNonAcceptance(null)).toBe(false); + expect(isNonAcceptance(undefined)).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( diff --git a/libs/rsvp-fold/src/rsvp-note.ts b/libs/rsvp-fold/src/rsvp-note.ts index 8df58f62..06871399 100644 --- a/libs/rsvp-fold/src/rsvp-note.ts +++ b/libs/rsvp-fold/src/rsvp-note.ts @@ -98,9 +98,47 @@ export function shouldEmitRsvpNote( } /** - * 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. + * Whether a stored fold marker represents an outstanding non-acceptance — a + * decline or a tentative the attendee has not since reversed. + * + * Pass the result as `shouldEmitRsvpNote`'s `hadPriorNonAccept`. Reading the + * VALUE rather than testing for presence is what lets one marker serve both + * this question and {@link alreadyFolded}. + */ +export function isNonAcceptance(stored: string | null | undefined): boolean { + return stored === "DECLINED" || stored === "TENTATIVE"; +} + +/** + * Whether this exact response has already been folded onto the event thread. + * + * Re-emitting a note the thread already carries is not harmless: the note + * upserts by key, but its unread intent is applied again and marks the thread + * unread for every recipient but the author — so a recipient who has since read + * the thread sees it go unread again for no new information. Providers re-deliver + * the same response routinely (a mail subscription that fires on `updated`, a + * re-sync, a webhook replay), so this must be checked before emitting. + * + * Compares the stored marker against the incoming `partstat`, so a genuine + * CHANGE of response (decline then accept) is not suppressed. + */ +export function alreadyFolded( + stored: string | null | undefined, + reply: RsvpReply +): boolean { + return stored === reply.partstat; +} + +/** + * Storage key holding the last response actually folded onto the event thread + * for one attendee on one event. Set on every response a connector emits a + * note for — a bare acceptance leaves it unset because a bare acceptance + * never gets a note (see {@link shouldEmitRsvpNote}). + * + * The value serves two questions from the same marker: {@link isNonAcceptance} + * reads it to tell whether an incoming acceptance is a genuine reversal, and + * {@link alreadyFolded} reads it to tell whether an incoming response merely + * repeats what this key already recorded. * * 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 From 099de922154918937b1f6ea5b0aabc86a29a2ac6 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 13:46:22 -0400 Subject: [PATCH 12/14] fix(rsvp-fold): correct the fold-marker doc, add call-order note, cover a repeated acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit priorRsvpKey's doc comment said a bare acceptance always leaves the marker unset, which isn't true when that acceptance reverses a prior decline or tentative — that path does emit a note and does store "ACCEPTED", which is exactly what lets a later repeat of it be recognised by alreadyFolded. Restate the real condition and add a one-line call-order note (read the marker, check alreadyFolded, then shouldEmitRsvpNote, write back only on the emit path) so a connector implementer has the sequence spelled out rather than having to infer it from two existing connectors. Also extend each connector's reversal test to re-deliver the same acceptance a third time and assert no third note is written — the sequence the old always-clear-on-accept store handled incorrectly, and one this suite previously only exercised for a repeated decline. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EojBeTMfriEnuDHpRfRh9x --- connectors/google/src/mail/sync.test.ts | 13 +++++++++++++ connectors/outlook/src/mail/sync.test.ts | 20 ++++++++++++++++++++ libs/rsvp-fold/src/rsvp-note.ts | 17 +++++++++++++++-- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/connectors/google/src/mail/sync.test.ts b/connectors/google/src/mail/sync.test.ts index 5d63ed7d..8f900f59 100644 --- a/connectors/google/src/mail/sync.test.ts +++ b/connectors/google/src/mail/sync.test.ts @@ -1137,6 +1137,19 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", () // non-acceptances — so a later repeat of this exact ACCEPTED is // recognised as already folded instead of re-emitting. expect(store.get(key)).toBe("ACCEPTED"); + + // A third pass re-delivers that same ACCEPTED response. This is the + // sequence the old store got wrong: it cleared its marker on every + // acceptance, so a repeated acceptance always looked unrecorded and + // would have re-emitted. The new store keeps the marker, so + // `alreadyFolded` recognises the repeat and no third note appears. + await processEmailThreadsFn( + host, + [rsvpThread("rsvp-accepted-again", replyIcs("ACCEPTED"))], + false, + "INBOX" + ); + expect(notes).toHaveLength(2); }); it("does not re-emit a note when the same conversation is processed again", async () => { diff --git a/connectors/outlook/src/mail/sync.test.ts b/connectors/outlook/src/mail/sync.test.ts index 1f17806a..50ca5170 100644 --- a/connectors/outlook/src/mail/sync.test.ts +++ b/connectors/outlook/src/mail/sync.test.ts @@ -538,6 +538,26 @@ describe("processConversationsFn — attendee responses fold onto the event", () // non-acceptances — so a later repeat of this exact ACCEPTED is // recognised as already folded instead of re-emitting. expect(map.get(key)).toBe("ACCEPTED"); + + // A third pass re-delivers that same ACCEPTED response. This is the + // sequence the old store got wrong: it cleared its marker on every + // acceptance, so a repeated acceptance always looked unrecorded and + // would have re-emitted. The new store keeps the marker, so + // `alreadyFolded` recognises the repeat and no third note appears. + const acceptAgainMsg = rsvpMessage( + "msg-reversal-3", + "conv-reversal-3", + "meetingAccepted", + uid + ); + mimeById.set("msg-reversal-3", rsvpMime(replyIcs("ACCEPTED", { uid }))); + await processConversationsFn( + host, + [{ messages: [acceptAgainMsg], attachmentsByMessageId: new Map(), parentHeaders: null }], + false, + "inbox" + ); + expect(notes).toHaveLength(2); }); it("does not re-emit a note when the same conversation is processed again", async () => { diff --git a/libs/rsvp-fold/src/rsvp-note.ts b/libs/rsvp-fold/src/rsvp-note.ts index 06871399..3a148bad 100644 --- a/libs/rsvp-fold/src/rsvp-note.ts +++ b/libs/rsvp-fold/src/rsvp-note.ts @@ -132,14 +132,27 @@ export function alreadyFolded( /** * Storage key holding the last response actually folded onto the event thread * for one attendee on one event. Set on every response a connector emits a - * note for — a bare acceptance leaves it unset because a bare acceptance - * never gets a note (see {@link shouldEmitRsvpNote}). + * note for — never on one it suppresses. A bare acceptance leaves it unset + * ONLY when there was no prior non-acceptance, because that's the one case + * where no note is emitted (see {@link shouldEmitRsvpNote}); a bare + * acceptance that reverses a prior decline or tentative DOES get a note, and + * DOES set this key to `"ACCEPTED"` — that stored `"ACCEPTED"` is exactly + * what lets a later repeat of that same acceptance be recognised by + * {@link alreadyFolded} instead of re-emitted. * * The value serves two questions from the same marker: {@link isNonAcceptance} * reads it to tell whether an incoming acceptance is a genuine reversal, and * {@link alreadyFolded} reads it to tell whether an incoming response merely * repeats what this key already recorded. * + * Call order matters and this library does not enforce it: read the stored + * marker, check {@link alreadyFolded} first, and only when that's false + * decide whether to emit via `shouldEmitRsvpNote(reply, + * isNonAcceptance(stored))`. Write the new partstat back with this key ONLY + * on the path that actually emits a note — never on the suppressed path, + * and never before deciding whether to emit, or the marker no longer + * reflects what the event thread carries. + * * 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 From a72644a0e743f4fdadff9da9ae31dd912b3b1218 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 13:53:20 -0400 Subject: [PATCH 13/14] test(rsvp-fold): cover a redelivered commented acceptance in both connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment-less acceptance redelivery is already suppressed by shouldEmitRsvpNote on its own once there's no outstanding non-acceptance — alreadyFolded never has to matter for that shape. A commented acceptance is the one response shouldEmitRsvpNote always says yes to (any comment earns a note), so alreadyFolded is the only thing standing between a redelivered commented acceptance and re-emitting a note — and an unread flip — on every redelivery. Add a test per connector that folds a commented acceptance, redelivers the identical message, and asserts no second note is written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EojBeTMfriEnuDHpRfRh9x --- connectors/google/src/mail/sync.test.ts | 24 ++++++++++++++++ connectors/outlook/src/mail/sync.test.ts | 36 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/connectors/google/src/mail/sync.test.ts b/connectors/google/src/mail/sync.test.ts index 8f900f59..c8cfafee 100644 --- a/connectors/google/src/mail/sync.test.ts +++ b/connectors/google/src/mail/sync.test.ts @@ -1173,6 +1173,30 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", () expect(links).toHaveLength(0); }); + it("does not re-emit a note when a commented acceptance is processed again", async () => { + // A bare (comment-less) repeat is suppressed by `shouldEmitRsvpNote` + // itself once there's no outstanding non-acceptance — `alreadyFolded` + // never even has to matter for that case. A COMMENTED acceptance is + // the one shape `shouldEmitRsvpNote` always says yes to on its own + // (its second rule: any comment earns a note), so `alreadyFolded` is + // the only thing standing between a redelivered commented acceptance + // and re-emitting on every redelivery. + const { host } = makeHost(); + const { notes, links } = captureSaves(host); + const thread = rsvpThread( + "rsvp-comment-reprocess", + replyIcs("ACCEPTED", { comment: "Looking forward to it" }) + ); + + await processEmailThreadsFn(host, [thread], false, "INBOX"); + expect(notes).toHaveLength(1); + + await processEmailThreadsFn(host, [thread], false, "INBOX"); + + expect(notes).toHaveLength(1); + expect(links).toHaveLength(0); + }); + it("does not let a decline on one occurrence suppress an acceptance on another", async () => { const { host, store } = makeHost(); const { notes } = captureSaves(host); diff --git a/connectors/outlook/src/mail/sync.test.ts b/connectors/outlook/src/mail/sync.test.ts index 50ca5170..56b3d05f 100644 --- a/connectors/outlook/src/mail/sync.test.ts +++ b/connectors/outlook/src/mail/sync.test.ts @@ -588,6 +588,42 @@ describe("processConversationsFn — attendee responses fold onto the event", () expect(links).toHaveLength(0); }); + it("does not re-emit a note when a commented acceptance is processed again", async () => { + // A bare (comment-less) repeat is suppressed by `shouldEmitRsvpNote` + // itself once there's no outstanding non-acceptance — `alreadyFolded` + // never even has to matter for that case. A COMMENTED acceptance is + // the one shape `shouldEmitRsvpNote` always says yes to on its own + // (its second rule: any comment earns a note), so `alreadyFolded` is + // the only thing standing between a redelivered commented acceptance + // and re-emitting on every redelivery. + const { host } = makeFoldHost(); + const { notes, links } = captureSaves(host); + const uid = "uid-rsvp-comment-reprocess@example.test"; + const msg = rsvpMessage( + "msg-comment-reprocess", + "conv-comment-reprocess", + "meetingAccepted", + uid + ); + mimeById.set( + "msg-comment-reprocess", + rsvpMime(replyIcs("ACCEPTED", { uid, comment: "Looking forward to it" })) + ); + const conversation = { + messages: [msg], + attachmentsByMessageId: new Map(), + parentHeaders: null, + }; + + await processConversationsFn(host, [conversation], false, "inbox"); + expect(notes).toHaveLength(1); + + await processConversationsFn(host, [conversation], false, "inbox"); + + expect(notes).toHaveLength(1); + expect(links).toHaveLength(0); + }); + it("does not let a decline on one occurrence suppress an acceptance on another", async () => { const { host, map } = makeFoldHost(); const { notes } = captureSaves(host); From bb67ec52ddcebce021826dba99d64e26fcb07656 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 14:14:04 -0400 Subject: [PATCH 14/14] docs(twister): changeset for the NewNote.unread default correction The branch corrects a doc comment that stated omitting `unread` leaves a thread's read state alone. It does not, and authors relying on that reading ship notes that silently mark threads unread. Record it in the changelog so the correction reaches consumers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3 --- .changeset/new-note-unread-default.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/new-note-unread-default.md diff --git a/.changeset/new-note-unread-default.md b/.changeset/new-note-unread-default.md new file mode 100644 index 00000000..65055b91 --- /dev/null +++ b/.changeset/new-note-unread-default.md @@ -0,0 +1,10 @@ +--- +"@plotday/twister": patch +--- + +Fixed: corrected the documented default for `NewNote.unread`. + +Omitting the flag was described as "leave read state alone". It is not — attaching +a note marks its thread unread for every recipient except the note's author, and +there is no outcome that leaves read state untouched. A note that should not raise +unread must pass an explicit `false`.