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`. 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/gmail-api.ts b/connectors/google/src/mail/gmail-api.ts index 0a865fe5..a1349797 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 { icsProp, parseIcsReply } from "@plotday/rsvp-fold"; export type GmailLabel = { @@ -767,25 +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. 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; -} - /** * MIME types an iCalendar part is delivered under. Google and Exchange use * `text/calendar`; a sizeable minority of senders use `application/ics`. @@ -927,60 +909,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 +936,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 +959,25 @@ 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 fromName = parseEmailAddress(getHeader(message, "From") ?? "")?.name ?? null; + const reply = parseIcsReply(ics, { name: fromName }); + if (!reply) continue; + + 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/connectors/google/src/mail/rsvp-note.ts b/connectors/google/src/mail/rsvp-note.ts deleted file mode 100644 index 7e4bd026..00000000 --- a/connectors/google/src/mail/rsvp-note.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * 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. - */ -import type { CalendarReply } from "./gmail-api"; - -const VERBS: Record = { - DECLINED: "declined", - ACCEPTED: "accepted", - TENTATIVE: "tentatively accepted", -}; - -/** - * Format an occurrence date the same way the cancellation note does - * (`calendar/sync.ts`), 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 - * cancellation note already does. - */ -function formatOccurrence(occurrence: Date, allDay: boolean): string { - return occurrence.toLocaleDateString("en-US", { - dateStyle: "long", - ...(allDay ? { timeZone: "UTC" } : {}), - }); -} - -/** Markdown blockquote, one `>` per line, so multi-line notes stay quoted. */ -function blockquote(text: string): string { - return text - .split("\n") - .map((line) => `> ${line}`.trimEnd()) - .join("\n"); -} - -/** - * The note body for one attendee response. Names the occurrence only when the - * 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 { - const who = reply.attendeeName ?? reply.attendeeEmail; - const verb = VERBS[reply.partstat]; - const where = reply.occurrence - ? ` the ${formatOccurrence(reply.occurrence, reply.allDay)} occurrence` - : ""; - const sentence = `${who} ${verb}${where}.`; - return reply.comment - ? `${sentence}\n\n${blockquote(reply.comment)}` - : sentence; -} - -/** - * Whether an attendee response warrants a note on the event thread. - * - * A bare acceptance repeats what the event's guest list already shows, so it - * earns no note. That is also the only way to keep it from raising unread: - * attaching a note surfaces the thread as unread for every recipient except - * the note's author, and no field a connector passes to `saveNote` can - * suppress that. Writing nothing is the guarantee. - * - * Everything else is genuinely new information and gets a note: - * a decline or a tentative changes whether the meeting works; an acceptance - * carrying a personal comment is a message from a person; and an acceptance - * that reverses an earlier decline or tentative is a real change of state, - * which `hadPriorNonAccept` reports from connector-local storage. - */ -export function shouldEmitRsvpNote( - reply: CalendarReply, - hadPriorNonAccept: boolean -): boolean { - if (reply.partstat !== "ACCEPTED") return true; - if (reply.comment) return true; - return hadPriorNonAccept; -} - -/** - * Storage key holding an outstanding decline/tentative for one attendee on one - * event. Written when such a response is folded, cleared when that attendee - * later accepts — so the store only ever holds unresolved non-acceptances. - * - * Not read from `schedule_contact`: the calendar product's own attendee sync - * writes that same field from the event roster, so by the time an RSVP email is - * processed it may already read as accepted and the prior decline is gone. This - * key records what this connector last folded, which is the actual question. - * - * Scoped by `occurrence` as well as `uid`: a reply to one occurrence of a - * recurring event carries the same series `uid` as every other occurrence, - * distinguished only by `RECURRENCE-ID`. Without the occurrence in the key, a - * decline on one occurrence would be read as an outstanding non-acceptance for - * an unrelated occurrence's later reply. `null` (a series-wide response) maps - * to the literal `"series"` segment. - */ -export function priorRsvpKey( - uid: string, - attendeeEmail: string, - occurrence: Date | null -): string { - return `rsvp:${uid}:${occurrence ? occurrence.toISOString() : "series"}:${attendeeEmail.toLowerCase()}`; -} diff --git a/connectors/google/src/mail/sync.test.ts b/connectors/google/src/mail/sync.test.ts index e3b5ae78..c8cfafee 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 { @@ -1132,8 +1132,69 @@ 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"); + + // 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 () => { + 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 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 () => { diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index 4aef73f8..2f026718 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -17,6 +17,13 @@ * returns a descriptor and lets the caller own the scheduling. */ import { enrichLinkContactsFromGoogle } from "@plotday/google-contacts"; +import { + alreadyFolded, + composeRsvpNote, + isNonAcceptance, + priorRsvpKey, + shouldEmitRsvpNote, +} from "@plotday/rsvp-fold"; import { baseEmail, canonicalizeEmail, @@ -38,7 +45,6 @@ import type { WebhookRequest } from "@plotday/twister/tools/network"; import { type AttachmentData, - type CalendarReply, GmailApi, GmailApiError, type GmailMessage, @@ -70,7 +76,6 @@ import { type ClassifiedSendError, classifySendError, } from "./gmail-send-errors"; -import { composeRsvpNote, priorRsvpKey, shouldEmitRsvpNote } from "./rsvp-note"; // --------------------------------------------------------------------------- // Persisted state shapes (shared with the connector) @@ -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/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..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", @@ -284,6 +286,41 @@ describe("GraphMailApi queries", () => { "microsoft.graph.eventMessage/event($select=iCalUId)" ); }); + + 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(); + }); }); describe("classifyOutlookCalendar", () => { @@ -346,13 +383,31 @@ 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("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..1799f623 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 = { @@ -187,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 = { @@ -224,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) : {}; } } @@ -303,6 +309,9 @@ 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. + // 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) { @@ -345,6 +354,9 @@ export class GraphMailApi { $filter: `conversationId eq ${odataQuote(conversationId)}`, $top: "100", $select: MESSAGE_SELECT_COLLECTION, + // 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++) { @@ -367,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", @@ -650,25 +681,48 @@ export function sortConversation(messages: GraphMessage[]): GraphMessage[] { ); } +/** + * 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", +}; + /** * 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. 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[], parentHeaders: GraphHeader[] | null -): { uid: string; kind: "reply" | "update" | "cancel" } | null { +): { + uid: string; + kind: "reply" | "update" | "cancel" | "rsvp"; + partstat?: RsvpReply["partstat"]; +} | null { const hdr = (parentHeaders ?? []).find( (h) => h.name.toLowerCase() === "x-plot-event-uid" ); @@ -679,6 +733,14 @@ 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) 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..a911efdb --- /dev/null +++ b/connectors/outlook/src/mail/outlook-ics-reply.test.ts @@ -0,0 +1,404 @@ +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 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 + * 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); +} + +/** + * 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; +} + +/** + * 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. + */ +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: real Microsoft-generated meeting responses carry an empty + * `X-MS-Has-Attach` header, unlike Google's duplicate `application/ics` + * attachment. + */ +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"], + 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="utf-8"', "Content-Transfer-Encoding: base64"], + encodeCalendar(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("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(); + }); + + 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 new file mode 100644 index 00000000..bef55c8d --- /dev/null +++ b/connectors/outlook/src/mail/outlook-ics-reply.ts @@ -0,0 +1,120 @@ +/** + * 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 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 decodeBase64Utf8(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`). + * + * `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, + 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 }); +} diff --git a/connectors/outlook/src/mail/sync.test.ts b/connectors/outlook/src/mail/sync.test.ts index 5960e3a3..56b3d05f 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,671 @@ 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 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"); + + // 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 () => { + 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 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); + 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([""]); + + // 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 () => { + 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); + // 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 () => { + 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([""]); + + // 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("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); + 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}` } }); + }); + +}); diff --git a/connectors/outlook/src/mail/sync.ts b/connectors/outlook/src/mail/sync.ts index 6879f03f..adfe5a09 100644 --- a/connectors/outlook/src/mail/sync.ts +++ b/connectors/outlook/src/mail/sync.ts @@ -31,21 +31,31 @@ 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 { + alreadyFolded, + composeRsvpNote, + isNonAcceptance, + priorRsvpKey, + shouldEmitRsvpNote, +} 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 +64,7 @@ import { type GraphMessage, type WellKnownFolders, } from "./graph-mail-api"; +import { extractOutlookReply } from "./outlook-ics-reply"; import { outlookSignals } from "./outlook-facets"; // --------------------------------------------------------------------------- @@ -210,6 +221,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 +1376,50 @@ export async function drainNotifiedMessagesFn( return retry.length > 0 ? { retry } : undefined; } +/** + * 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) { + 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; +} + export async function processConversationsFn( host: OutlookMailSyncHost, items: ConversationItem[], @@ -1421,6 +1482,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,8 +1518,157 @@ 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); + // 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, isNonAcceptance(stored))) { + 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. + // + // 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 + // 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; - 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; @@ -1482,11 +1699,26 @@ 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) { + // `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}`, @@ -1498,8 +1730,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) { @@ -1514,7 +1749,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/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/ics-reply.test.ts b/libs/rsvp-fold/src/ics-reply.test.ts new file mode 100644 index 00000000..5e195b36 --- /dev/null +++ b/libs/rsvp-fold/src/ics-reply.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; + +import { parseIcsReply } from "./ics-reply"; + +const FALLBACK = { name: null }; + +/** + * 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 }); + 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 }); + 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 }); + expect(r).toMatchObject({ + partstat: "ACCEPTED", + attendeeName: "Ana Ruiz", + attendeeEmail: "ana@example.test", + comment: "Uh huh, here's my comment", + }); + }); + + 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", + attendeeEmail: "beth@example.test", + }); + }); + + 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:"); + expect(parseIcsReply(noAddress, { name: "Notification Sender" })).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..904c3526 --- /dev/null +++ b/libs/rsvp-fold/src/ics-reply.ts @@ -0,0 +1,165 @@ +/** + * 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 display name for when + * the `ATTENDEE` line omits a `CN` 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'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; +} + +/** + * 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 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; +}; + +/** + * Parse one attendee response from a `METHOD:REPLY` iCalendar body. + * + * Returns `null` when the body isn't a reply, carries no usable `ATTENDEE` + * (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, + 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, ""); + 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 new file mode 100644 index 00000000..c41d412b --- /dev/null +++ b/libs/rsvp-fold/src/index.ts @@ -0,0 +1,10 @@ +export { + alreadyFolded, + composeRsvpNote, + isNonAcceptance, + shouldEmitRsvpNote, + priorRsvpKey, + type RsvpReply, +} from "./rsvp-note"; + +export { parseIcsReply, icsProp, type IcsReplyFallback } from "./ics-reply"; diff --git a/connectors/google/src/mail/rsvp-note.test.ts b/libs/rsvp-fold/src/rsvp-note.test.ts similarity index 78% rename from connectors/google/src/mail/rsvp-note.test.ts rename to libs/rsvp-fold/src/rsvp-note.test.ts index a243e63d..ec6f95cd 100644 --- a/connectors/google/src/mail/rsvp-note.test.ts +++ b/libs/rsvp-fold/src/rsvp-note.test.ts @@ -1,19 +1,22 @@ import { describe, expect, it } from "vitest"; -import type { CalendarReply } from "./gmail-api"; -import { composeRsvpNote, shouldEmitRsvpNote, priorRsvpKey } from "./rsvp-note"; - -function reply(overrides: Partial = {}): CalendarReply { +import { + alreadyFolded, + composeRsvpNote, + isNonAcceptance, + shouldEmitRsvpNote, + priorRsvpKey, +} from "./rsvp-note"; +import type { RsvpReply } from "./rsvp-note"; + +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, }; } @@ -110,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 new file mode 100644 index 00000000..3a148bad --- /dev/null +++ b/libs/rsvp-fold/src/rsvp-note.ts @@ -0,0 +1,174 @@ +/** + * Presentation for attendee responses folded onto a calendar event's thread. + * + * 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. + */ + +/** + * 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", +}; + +/** + * 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 + * cancellation note already does. + */ +function formatOccurrence(occurrence: Date, allDay: boolean): string { + return occurrence.toLocaleDateString("en-US", { + dateStyle: "long", + ...(allDay ? { timeZone: "UTC" } : {}), + }); +} + +/** Markdown blockquote, one `>` per line, so multi-line notes stay quoted. */ +function blockquote(text: string): string { + return text + .split("\n") + .map((line) => `> ${line}`.trimEnd()) + .join("\n"); +} + +/** + * The note body for one attendee response. Names the occurrence only when the + * 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: RsvpReply): string { + const who = reply.attendeeName ?? reply.attendeeEmail; + const verb = VERBS[reply.partstat]; + const where = reply.occurrence + ? ` the ${formatOccurrence(reply.occurrence, reply.allDay)} occurrence` + : ""; + const sentence = `${who} ${verb}${where}.`; + return reply.comment + ? `${sentence}\n\n${blockquote(reply.comment)}` + : sentence; +} + +/** + * Whether an attendee response warrants a note on the event thread. + * + * A bare acceptance repeats what the event's guest list already shows, so it + * earns no note. That is also the only way to keep it from raising unread: + * attaching a note surfaces the thread as unread for every recipient except + * the note's author, and no field a connector passes to `saveNote` can + * suppress that. Writing nothing is the guarantee. + * + * Everything else is genuinely new information and gets a note: + * a decline or a tentative changes whether the meeting works; an acceptance + * carrying a personal comment is a message from a person; and an acceptance + * that reverses an earlier decline or tentative is a real change of state, + * which `hadPriorNonAccept` reports from connector-local storage. + */ +export function shouldEmitRsvpNote( + reply: RsvpReply, + hadPriorNonAccept: boolean +): boolean { + if (reply.partstat !== "ACCEPTED") return true; + if (reply.comment) return true; + return hadPriorNonAccept; +} + +/** + * 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 — 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 + * key records what this connector last folded, which is the actual question. + * + * Scoped by `occurrence` as well as `uid`: a reply to one occurrence of a + * recurring event carries the same series `uid` as every other occurrence, + * distinguished only by `RECURRENCE-ID`. Without the occurrence in the key, a + * decline on one occurrence would be read as an outstanding non-acceptance for + * an unrelated occurrence's later reply. `null` (a series-wide response) maps + * to the literal `"series"` segment. + */ +export function priorRsvpKey( + uid: string, + attendeeEmail: string, + occurrence: Date | null +): string { + return `rsvp:${uid}:${occurrence ? occurrence.toISOString() : "series"}:${attendeeEmail.toLowerCase()}`; +} diff --git a/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..107e4995 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 @@ -226,6 +229,9 @@ importers: connectors/outlook: dependencies: + '@plotday/rsvp-fold': + specifier: workspace:^ + version: link:../../libs/rsvp-fold '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -302,6 +308,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': diff --git a/twister/src/plot.ts b/twister/src/plot.ts index b0ccec7d..f2853e88 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -931,12 +931,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.