From bcefcd34f55efbf8b6faa240adc7e7ee193ba36a Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 3 Aug 2026 20:51:08 -0400 Subject: [PATCH] feat(rsvp-fold): own the fold sequence in a shared foldRsvp() helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library exported the predicates for folding an attendee's response onto its event's thread but stopped short of the sequence around them, so each mail connector restated the same ~40 lines: build the marker key, read it, check for a repeat, decide whether the response earns a note, compose it, save it, record it. The order is load-bearing and none of it was enforced — checking the repeat second re-emits a re-delivered commented acceptance, and writing the marker on the suppressed path leaves it describing something the event thread does not carry. Both mistakes surface the same way: a thread people had already read goes unread again. `foldRsvp()` owns that order. Genuinely per-connector concerns stay injected, because they differ for real reasons: reading and writing the marker (write through immediately, or collect and flush once per pass to stay inside the request budget), saving the note, and the note's key, timestamp and unread flag. Every outcome — emitted, suppressed, already folded — means the response was dealt with, so each caller records "this message was folded" unconditionally after the call, in whatever form is its own: an in-pass set, durable per-thread metadata, or both. The returned outcome names which path ran. Gmail, Outlook and iCloud mail now call it. No behaviour change: their test suites pass unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3 --- connectors/apple/src/mail/sync.ts | 99 ++++------- connectors/google/src/mail/sync.ts | 101 +++-------- connectors/outlook/src/mail/sync.ts | 85 ++------- libs/rsvp-fold/README.md | 8 + libs/rsvp-fold/src/fold-rsvp.test.ts | 255 +++++++++++++++++++++++++++ libs/rsvp-fold/src/fold-rsvp.ts | 194 ++++++++++++++++++++ libs/rsvp-fold/src/index.ts | 2 + 7 files changed, 536 insertions(+), 208 deletions(-) create mode 100644 libs/rsvp-fold/src/fold-rsvp.test.ts create mode 100644 libs/rsvp-fold/src/fold-rsvp.ts diff --git a/connectors/apple/src/mail/sync.ts b/connectors/apple/src/mail/sync.ts index fce985a9..1a5c3857 100644 --- a/connectors/apple/src/mail/sync.ts +++ b/connectors/apple/src/mail/sync.ts @@ -1,12 +1,4 @@ -import { - alreadyFolded, - composeRsvpNote, - icsProp, - isNonAcceptance, - parseIcsReply, - priorRsvpKey, - shouldEmitRsvpNote, -} from "@plotday/rsvp-fold"; +import { foldRsvp, icsProp, parseIcsReply } from "@plotday/rsvp-fold"; import type { ActorId } from "@plotday/twister"; import type { ImapMailboxStatus, ImapSession } from "@plotday/twister/tools/imap"; @@ -90,10 +82,11 @@ export type ThreadMeta = { * must never flip: this is per-MESSAGE, and exists so a root whose bundling * question is already settled still looks at messages that arrived since. * - * Required, not an optimisation. `alreadyFolded` stops a duplicate note but - * not the IMAP fetch, and inside the 30-day rescan window an RSVP would - * otherwise be re-fetched on every pass — roughly 2,900 times, at up to two - * round-trips each, against a ~1,000-request execution budget. + * Required, not an optimisation. `foldRsvp`'s repeat check stops a + * duplicate note but not the IMAP fetch, and inside the 30-day rescan + * window an RSVP would otherwise be re-fetched on every pass — roughly + * 2,900 times, at up to two round-trips each, against a ~1,000-request + * execution budget. * * Capped at SEEN_ICS_MAX, oldest dropped. Growth is bounded in practice by * how many responses one meeting draws, but this document is rewritten every @@ -121,9 +114,10 @@ export type ThreadMeta = { * key can only be dropped together with its `seenIcs` entry, which puts the * message back among the unexamined and has the next pass re-fetch and * re-fold it. (The response's own `rsvp:` marker is a separate, uncapped - * key, so `alreadyFolded` still suppresses a second note.) An independent - * cap would have no such backstop — it would drop a folded key while the - * message stayed un-fetched, and the note would resurface permanently. + * key, so `foldRsvp` still recognises the repeat and writes no second + * note.) An independent cap would have no such backstop — it would drop a + * folded key while the message stayed un-fetched, and the note would + * resurface permanently. */ foldedIcs?: string[]; }; @@ -423,9 +417,11 @@ export async function detectCalendarBundles( // `dedupeCopies` only collapses them later, inside `transformMessages` — // `unexamined` was filtered against a snapshot of `seen`, so both copies // are in it. The second copy has nothing new to read, and routing it - // again would emit its response note twice: the marker written below is - // flushed once at the end of the pass, so the second copy would still - // read the pre-pass value and look un-folded. + // again would emit its response note twice: the marker is collected + // rather than written through (see `writeMarker` below) and flushed + // once at the end of the pass, so the second copy would still read the + // pre-pass value and look un-folded. This guard is the in-pass dedup + // `foldRsvp` requires of any connector that batches its markers. if (seen.has(noteKeyOf(m))) continue; const part = (m.attachments ?? []).find((a) => isCalendarAttachment(a.mimeType))!; @@ -449,50 +445,31 @@ export async function detectCalendarBundles( // reply is not an answer to the bundling question. const replyUid = icsProp(ics, "UID"); if (replyUid) { - const priorKey = priorRsvpKey(replyUid, reply.attendeeEmail, reply.occurrence); - const stored = await host.get(priorKey); - - // Order is the library's documented contract: `alreadyFolded` - // FIRST, and only when it is false decide whether to emit. - // Re-emitting a note the thread already carries re-applies its - // unread intent and drags the thread back to unread for everyone - // who had read it — and a response inside the 30-day rescan window - // is re-read on every pass. - if ( - !alreadyFolded(stored, reply) && - shouldEmitRsvpNote(reply, isNonAcceptance(stored)) - ) { - // `saveNote` returns null when no thread carries `icaluid:` - // yet (the calendar event has not synced); `deferUntilThread` has - // the platform hold the note and attach it once that thread - // appears. - await host.integrations.saveNote({ - thread: { source: `icaluid:${replyUid}` }, + // The decision order (repeat check first, marker written only on + // the emitting path) and the reasoning behind it live once, on + // `foldRsvp`. Only what is iCloud-specific stays here — including + // the batched marker write below, which this connector needs + // because a response inside the 30-day rescan window would + // otherwise cost a store request per response per pass. + await foldRsvp({ + uid: replyUid, + reply, + // `created` may be absent here — `foldRsvp` leaves the field + // off the note rather than sending an explicit undefined. + note: { key: noteKeyOf(m), - content: composeRsvpNote(reply), - contentType: "markdown", - ...(m.date ? { created: m.date } : {}), - 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 marks the thread - // unread for every recipient except its author, so only an - // explicit false overrides it. + created: m.date, unread: !initialRoots.has(root), - deferUntilThread: true, - }); - // Recorded ONLY on the path that emits, and regardless of the - // 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. - // The marker holds the last response actually folded onto the - // thread — for every emitted response, acceptances included, - // which is what lets `alreadyFolded` recognise a repeat of ANY - // partstat. - rsvpMarkers.push([priorKey, reply.partstat]); - } + }, + readMarker: (key) => host.get(key), + // Collected, not written: flushed in ONE `setMany` in the + // `finally` below, so a `saveNote` that throws later in the pass + // cannot take the markers of responses already written with it. + writeMarker: (key, partstat) => { + rsvpMarkers.push([key, partstat]); + }, + saveNote: (note) => host.integrations.saveNote(note), + }); // Folded whether or not a note was written — a bare acceptance is // dropped from the mail thread rather than left to become an email diff --git a/connectors/google/src/mail/sync.ts b/connectors/google/src/mail/sync.ts index 2f026718..66988547 100644 --- a/connectors/google/src/mail/sync.ts +++ b/connectors/google/src/mail/sync.ts @@ -17,13 +17,7 @@ * 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 { foldRsvp } from "@plotday/rsvp-fold"; import { baseEmail, canonicalizeEmail, @@ -1562,65 +1556,30 @@ async function saveTransformedThread( const replies = extractCalendarReplies(thread.messages ?? [], icsByMessage); if (replies.length > 0) { for (const reply of replies) { - const priorKey = priorRsvpKey(reply.uid, reply.attendeeEmail, reply.occurrence); - // 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, isNonAcceptance(stored))) { - foldedMessageIds.add(reply.messageId); - continue; - } - - // saveNote returns null when no thread carries `icaluid:` yet — - // the calendar event has not synced. deferUntilThread has the - // platform hold the note and attach it once that thread appears, - // instead of us leaving it in the email thread as a fallback. The - // return value is not consulted: null now covers both "parked" and, - // in principle, "genuinely rejected", and deferUntilThread means we - // no longer need to tell those apart. - await host.tools.integrations.saveNote({ - thread: { source: `icaluid:${reply.uid}` }, - key: reply.messageId, - content: composeRsvpNote(reply), - contentType: "markdown", - created: reply.sourceCreatedAt, - author: { - email: reply.attendeeEmail, - ...(reply.attendeeName ? { name: reply.attendeeName } : {}), + // The decision order (repeat check first, marker written only on the + // emitting path) and the reasoning behind it live once, on + // `foldRsvp`. Only what is Gmail-specific stays here. + await foldRsvp({ + uid: reply.uid, + reply, + note: { + key: reply.messageId, + created: reply.sourceCreatedAt, + unread: !initialSync, }, - // 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, + readMarker: (key) => host.get(key), + writeMarker: (key, partstat) => host.set(key, partstat), + saveNote: (note) => host.tools.integrations.saveNote(note), }); - // Folded either way. A miss is now held by the platform rather than - // returned to us, so a responses-only conversation never creates an - // email thread even when the event is missing — the note is + + // Folded on every outcome — emitted, suppressed, or already folded. + // A response never stays in the email thread: dropping it is what + // keeps a responses-only conversation from becoming an email thread + // of its own, and a suppressed bare acceptance is folded precisely + // because writing no note is the right answer for it. + // + // A note whose event has not synced is held by the platform rather + // than returned to us, so this holds even on a miss — the note is // guaranteed to land eventually, but only if the sweep that attaches // parked notes keeps working. A regression there loses the response // silently instead of leaving it visibly stranded in the inbox. @@ -1637,20 +1596,6 @@ async function saveTransformedThread( // that email thread for responses-only conversations even at the // cost of losing the response outright for calendar-less recipients. foldedMessageIds.add(reply.messageId); - // Recorded regardless of `noteId`: the decision to emit is what this - // bookkeeping tracks, and with deferUntilThread the platform now - // guarantees the note lands eventually even on a miss. Previously - // this only ran on an immediate attach, with the drain's own retry - // recording it later on success — with the drain gone, gating on - // `noteId` here 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); } if (foldedMessageIds.size > 0) { diff --git a/connectors/outlook/src/mail/sync.ts b/connectors/outlook/src/mail/sync.ts index adfe5a09..a2820324 100644 --- a/connectors/outlook/src/mail/sync.ts +++ b/connectors/outlook/src/mail/sync.ts @@ -37,13 +37,7 @@ import type { } 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 { foldRsvp } from "@plotday/rsvp-fold"; import { enrichLinkContactsFromOutlook } from "./enrich"; import { @@ -1550,70 +1544,23 @@ export async function processConversationsFn( 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, + // The decision order (repeat check first, marker written only on the + // emitting path) and the reasoning behind it live once, on + // `foldRsvp`. Only what is Graph-specific stays here. + await foldRsvp({ + uid, + reply, + note: { key: noteKey, created: messageDate(m), unread: !initialSync }, + readMarker: (key) => host.get(key), + writeMarker: (key, partstat) => host.set(key, partstat), + saveNote: (note) => host.tools.integrations.saveNote(note), }); + // Folded on every outcome — emitted, suppressed, or already folded. + // A response never stays in the mail thread: dropping it is what + // keeps a responses-only conversation from becoming an email thread + // of its own, and a suppressed bare acceptance is folded precisely + // because writing no note is the right answer for it. 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 diff --git a/libs/rsvp-fold/README.md b/libs/rsvp-fold/README.md index 8e509d3a..b1dc0635 100644 --- a/libs/rsvp-fold/README.md +++ b/libs/rsvp-fold/README.md @@ -5,6 +5,14 @@ tentative) onto the event's thread as a note. ## What it does +- `foldRsvp({ uid, reply, note, readMarker, writeMarker, saveNote })` — folds + one response onto its event's thread, owning the order the rules below have + to be applied in: check for a repeat first, only then decide whether the + response earns a note, and record it only when one was actually written. + Getting that order wrong re-raises unread on threads people have already + read, so connectors call this rather than sequencing the predicates + themselves. Reading and writing the marker, and saving the note, are + injected — connectors differ in how they batch those. - `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. diff --git a/libs/rsvp-fold/src/fold-rsvp.test.ts b/libs/rsvp-fold/src/fold-rsvp.test.ts new file mode 100644 index 00000000..67ac2bc9 --- /dev/null +++ b/libs/rsvp-fold/src/fold-rsvp.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; + +import { foldRsvp } from "./fold-rsvp"; +import { priorRsvpKey, type RsvpReply } from "./rsvp-note"; + +const UID = "uid-1@example.test"; + +function reply(overrides: Partial = {}): RsvpReply { + return { + partstat: "DECLINED", + attendeeName: "Beth Round", + attendeeEmail: "beth@example.test", + occurrence: null, + allDay: false, + comment: null, + ...overrides, + }; +} + +/** + * A connector stand-in: an in-memory marker store, a note sink, and a log of + * the effects in the order they happened (so "marker written after the note" + * is observable, not just "both happened"). + */ +function harness(stored: Record = {}) { + const markers = new Map(Object.entries(stored)); + const notes: Record[] = []; + const effects: string[] = []; + return { + markers, + notes, + effects, + ports: { + readMarker: async (key: string) => { + effects.push(`read:${key}`); + return markers.get(key); + }, + writeMarker: async (key: string, partstat: string) => { + effects.push(`write:${key}=${partstat}`); + markers.set(key, partstat); + }, + saveNote: async (note: Record) => { + effects.push("saveNote"); + notes.push(note); + // Deferred notes resolve to nothing when the event has not synced yet. + return null; + }, + }, + }; +} + +/** The note fields a connector supplies; overridable per test. */ +function noteFields(overrides: Partial<{ key: string; created?: Date; unread: boolean }> = {}) { + return { key: "message-1@example.test", unread: true, ...overrides }; +} + +describe("foldRsvp", () => { + it("emits a note for a decline and records the response", async () => { + const h = harness(); + + const outcome = await foldRsvp({ + uid: UID, + reply: reply(), + note: noteFields({ created: new Date("2026-08-03T09:00:00Z") }), + ...h.ports, + }); + + expect(outcome).toBe("emitted"); + expect(h.notes).toHaveLength(1); + expect(h.notes[0]).toEqual({ + thread: { source: `icaluid:${UID}` }, + key: "message-1@example.test", + content: "Beth Round declined.", + contentType: "markdown", + created: new Date("2026-08-03T09:00:00Z"), + author: { email: "beth@example.test", name: "Beth Round" }, + unread: true, + deferUntilThread: true, + }); + expect(h.markers.get(priorRsvpKey(UID, "beth@example.test", null))).toBe("DECLINED"); + }); + + it("writes no note and no marker for a bare acceptance", async () => { + const h = harness(); + + const outcome = await foldRsvp({ + uid: UID, + reply: reply({ partstat: "ACCEPTED" }), + note: noteFields(), + ...h.ports, + }); + + // The whole reason the helper exists: a note is the only thing that could + // mark the organiser's event thread unread, so a response that says + // nothing new writes nothing at all. + expect(outcome).toBe("suppressed"); + expect(h.notes).toHaveLength(0); + expect(h.markers.size).toBe(0); + }); + + it("writes nothing at all when the same response was already folded", async () => { + const key = priorRsvpKey(UID, "beth@example.test", null); + const h = harness({ [key]: "DECLINED" }); + + const outcome = await foldRsvp({ + uid: UID, + reply: reply({ partstat: "DECLINED" }), + note: noteFields(), + ...h.ports, + }); + + expect(outcome).toBe("already-folded"); + expect(h.notes).toHaveLength(0); + expect(h.effects).toEqual([`read:${key}`]); + }); + + it("emits an acceptance that reverses a prior decline, and updates the marker", async () => { + const key = priorRsvpKey(UID, "beth@example.test", null); + const h = harness({ [key]: "DECLINED" }); + + const outcome = await foldRsvp({ + uid: UID, + reply: reply({ partstat: "ACCEPTED" }), + note: noteFields(), + ...h.ports, + }); + + expect(outcome).toBe("emitted"); + expect(h.notes[0]!.content).toBe("Beth Round accepted."); + // Now ACCEPTED, so a later repeat of this same acceptance is recognised as + // already folded instead of emitted again. + expect(h.markers.get(key)).toBe("ACCEPTED"); + }); + + it("recognises a re-delivered bare acceptance as a repeat, not merely as uninteresting", async () => { + const key = priorRsvpKey(UID, "beth@example.test", null); + // The state left by the test above: an acceptance that DID earn a note. + const h = harness({ [key]: "ACCEPTED" }); + + const outcome = await foldRsvp({ + uid: UID, + reply: reply({ partstat: "ACCEPTED" }), + note: noteFields(), + ...h.ports, + }); + + // A stored "ACCEPTED" exists precisely because that acceptance earned a + // note (it reversed something), so this repeat has to be read as a repeat. + // Nothing is written either way here — the case where the repeat check is + // the only thing standing between a re-delivery and a re-raised unread is + // the commented acceptance below. + expect(outcome).toBe("already-folded"); + expect(h.notes).toHaveLength(0); + }); + + it("checks the repeat first, so a re-delivered commented acceptance is not re-emitted", async () => { + const key = priorRsvpKey(UID, "beth@example.test", null); + const h = harness({ [key]: "ACCEPTED" }); + + // A comment makes `shouldEmitRsvpNote` say yes unconditionally, so ONLY + // the repeat check standing ahead of it stops the second delivery of this + // message from re-applying its unread intent to the event thread — the + // one case where dropping that check writes a note it must not write. + const outcome = await foldRsvp({ + uid: UID, + reply: reply({ partstat: "ACCEPTED", comment: "Running 10 minutes late" }), + note: noteFields(), + ...h.ports, + }); + + expect(outcome).toBe("already-folded"); + expect(h.notes).toHaveLength(0); + }); + + it("writes the marker only after the note, and only on the emitting path", async () => { + const key = priorRsvpKey(UID, "beth@example.test", null); + const h = harness(); + + await foldRsvp({ uid: UID, reply: reply(), note: noteFields(), ...h.ports }); + // A marker written before the note would survive a throwing save and + // suppress the response permanently. + expect(h.effects).toEqual([`read:${key}`, "saveNote", `write:${key}=DECLINED`]); + + // Suppressed and already-folded paths write no marker at all: it must keep + // describing what the event thread actually carries. + const suppressed = harness(); + await foldRsvp({ + uid: UID, + reply: reply({ partstat: "ACCEPTED" }), + note: noteFields(), + ...suppressed.ports, + }); + expect(suppressed.effects).toEqual([`read:${key}`]); + + const repeat = harness({ [key]: "DECLINED" }); + await foldRsvp({ uid: UID, reply: reply(), note: noteFields(), ...repeat.ports }); + expect(repeat.effects).toEqual([`read:${key}`]); + }); + + it("leaves `created` off the note entirely when the connector has no timestamp", async () => { + const h = harness(); + + await foldRsvp({ uid: UID, reply: reply(), note: noteFields(), ...h.ports }); + + expect(h.notes[0]).not.toHaveProperty("created"); + }); + + it("passes the connector's unread choice through unchanged", async () => { + const h = harness(); + + await foldRsvp({ + uid: UID, + reply: reply(), + note: noteFields({ unread: false }), + ...h.ports, + }); + + // History ingested on first connect must not light up the event thread. + expect(h.notes[0]!.unread).toBe(false); + }); + + it("scopes the marker to the occurrence a response answered", async () => { + const occurrence = new Date("2026-08-04T14:00:00Z"); + const h = harness(); + + await foldRsvp({ + uid: UID, + reply: reply({ occurrence }), + note: noteFields(), + ...h.ports, + }); + + // A decline on one occurrence must not read as an outstanding + // non-acceptance for a different occurrence of the same series. + expect(h.markers.get(priorRsvpKey(UID, "beth@example.test", occurrence))).toBe( + "DECLINED" + ); + expect(h.markers.has(priorRsvpKey(UID, "beth@example.test", null))).toBe(false); + }); + + it("addresses the note by the event's uid, and names the responder by address when unnamed", async () => { + const h = harness(); + + await foldRsvp({ + uid: "other-uid@example.test", + reply: reply({ attendeeName: null }), + note: noteFields(), + ...h.ports, + }); + + expect(h.notes[0]!.thread).toEqual({ source: "icaluid:other-uid@example.test" }); + expect(h.notes[0]!.content).toBe("beth@example.test declined."); + expect(h.notes[0]!.author).toEqual({ email: "beth@example.test" }); + }); +}); diff --git a/libs/rsvp-fold/src/fold-rsvp.ts b/libs/rsvp-fold/src/fold-rsvp.ts new file mode 100644 index 00000000..086acc73 --- /dev/null +++ b/libs/rsvp-fold/src/fold-rsvp.ts @@ -0,0 +1,194 @@ +/** + * The fold itself: decide, compose, save, record — in the one order that is + * correct — for a single attendee response. + * + * The predicates in `./rsvp-note` each answer one question and enforce no + * order between them. Every connector that folds responses onto event threads + * needs the same sequence around them, and getting the sequence wrong is not + * cosmetic: it re-raises unread on threads people have already read, or leaves + * the marker describing something other than what the event thread carries. + * `foldRsvp` owns that sequence so no caller has to restate it. + */ + +import type { NewNote } from "@plotday/twister"; + +import { + alreadyFolded, + composeRsvpNote, + isNonAcceptance, + priorRsvpKey, + shouldEmitRsvpNote, + type RsvpReply, +} from "./rsvp-note"; + +/** + * What `foldRsvp` did with one response. + * + * Every outcome means "this message has been dealt with as a response" — the + * caller drops its note from the mail thread in all three cases. A bare + * acceptance is `"suppressed"`, not "ignored": it belongs on the event, and + * the event's guest list already shows it, so the right note is no note. + */ +export type RsvpFoldOutcome = + /** The stored marker already records this exact response; nothing written. */ + | "already-folded" + /** Nothing new to say (a bare acceptance); no note, no marker. */ + | "suppressed" + /** A note was saved and the marker updated to this response. */ + | "emitted"; + +/** + * The parts of the emitted note only the connector can supply. Everything + * else about the note — its content, its target thread, its author, that it + * is markdown and deferred — is the shared fold rule and is set here. + */ +export type RsvpNoteFields = { + /** + * `note.key`, so a re-delivered response upserts rather than duplicating. + * Each connector derives it from its own message identity. + */ + key: string; + /** + * `note.created` — the response message's own timestamp. Omit when the + * provider gave none; the field is then left off the note entirely rather + * than sent as an explicit `undefined`. + */ + created?: Date; + /** + * `note.unread`. Always passed explicitly, on every path: an omitted flag + * does NOT mean "leave read state alone" — attaching a note already marks + * the thread unread for every recipient except its author, so only an + * explicit `false` overrides it. Connectors pass `false` for responses + * ingested from history on first connect, `true` for live mail. + */ + unread: boolean; +}; + +export type FoldRsvpOptions = { + /** + * The event's iCalendar UID. Addresses the event thread (`icaluid:`) + * and scopes the fold marker. Read from the response's own calendar part, + * not from the attendee line. + */ + uid: string; + /** The attendee's response. */ + reply: RsvpReply; + /** See {@link RsvpNoteFields}. */ + note: RsvpNoteFields; + /** + * Read the stored fold marker for `key`. Injected because connectors read + * their state differently — directly, or from a per-pass cache. + * + * Read on every response, not just a bare acceptance: the repeat check + * needs the stored value on every path, so there is no cheaper path that + * skips this round-trip. + */ + readMarker: (key: string) => Promise; + /** + * Record `partstat` under `key`. Called ONLY on the emitting path, after + * the note is saved, and regardless of what `saveNote` returned — a + * deferred note returns no id, and gating on it would leave a deferred + * decline unrecorded forever, wrongly treating a later bare acceptance as + * reversing nothing. + * + * Injected because durability differs by connector: writing through + * immediately is the simple choice, while a connector that folds many + * responses in one pass may collect the markers and flush them in a single + * batched write. Batching carries two requirements, and missing either one + * re-emits notes and re-raises unread on threads people have already read: + * + * 1. **Flush even when a later response throws** (a `finally`, not the happy + * path), or the markers of responses already written are lost with it. + * 2. **Dedupe repeat deliveries WITHIN the pass by other means.** A batched + * marker is invisible to `readMarker` until it is flushed, so the same + * response reaching this function twice in one pass reads the pre-pass + * value both times, looks un-folded both times, and is emitted twice. A + * connector that can see one message more than once per pass — two + * mailbox copies of it, a conversation re-fetched under two ids — needs + * its own in-pass guard. + */ + writeMarker: ( + key: string, + partstat: RsvpReply["partstat"] + ) => void | Promise; + /** + * Save the composed note. Injected rather than taking a tool handle so this + * library needs no runtime dependency on the SDK. + * + * The return value is deliberately not consulted: the note is deferred, so + * `null` means "held until the event thread appears", not "rejected". + */ + saveNote: (note: NewNote) => Promise; +}; + +/** + * Fold one attendee response onto its event's thread. + * + * The order below is the whole point of this function, and it is the order the + * predicates document but cannot enforce: + * + * 1. **Read the marker.** It records the last response this connector actually + * folded onto the event thread for this attendee on this event or + * occurrence — see {@link priorRsvpKey}. + * 2. **{@link alreadyFolded} FIRST.** Providers re-deliver the same response + * routinely: a mail subscription that fires on `updated` as well as + * `created`, a history replay, a backfill overlap, a re-scan window that + * re-reads the same message on every pass. The note upserts by key, so + * re-saving it would not duplicate it — but its unread intent is applied + * again and drags the thread back to unread for everyone who had read it. + * Comparing against the stored partstat (not merely its presence) means a + * genuine CHANGE of response is never caught by this; the accepted cost is + * that an attendee who edits only their comment on an unchanged response + * gets no updated note. + * 3. **Only then decide whether to emit**, via `shouldEmitRsvpNote(reply, + * isNonAcceptance(stored))`. A bare acceptance says nothing the event's + * guest list does not already show, and writing no note is the ONLY way to + * keep it from marking the thread unread — attaching a note surfaces the + * thread as unread for every recipient except the note's author, and no + * field passed to `saveNote` suppresses that. + * 4. **Write the marker only on the emitting path.** Never on the suppressed + * path, and never before the note is saved, or the marker stops describing + * what the event thread actually carries. It is written for every emitted + * response, acceptances included — that stored `"ACCEPTED"` is what lets a + * later repeat of the same acceptance be recognised in step 2. + * + * The note itself is addressed by `{ source: "icaluid:" }` and deferred: + * `saveNote` resolves nothing when the calendar event has not synced yet, so + * the platform holds the note and attaches it once that thread appears. + * + * Every outcome means the response has been dealt with — see + * {@link RsvpFoldOutcome}. Callers do their own "this message was folded" + * bookkeeping from that, because what they must record (an in-pass set, + * durable per-thread metadata, or both) is connector-specific. + */ +export async function foldRsvp({ + uid, + reply, + note, + readMarker, + writeMarker, + saveNote, +}: FoldRsvpOptions): Promise { + const markerKey = priorRsvpKey(uid, reply.attendeeEmail, reply.occurrence); + const stored = await readMarker(markerKey); + + if (alreadyFolded(stored, reply)) return "already-folded"; + if (!shouldEmitRsvpNote(reply, isNonAcceptance(stored))) return "suppressed"; + + await saveNote({ + thread: { source: `icaluid:${uid}` }, + key: note.key, + content: composeRsvpNote(reply), + contentType: "markdown", + ...(note.created ? { created: note.created } : {}), + author: { + email: reply.attendeeEmail, + ...(reply.attendeeName ? { name: reply.attendeeName } : {}), + }, + unread: note.unread, + deferUntilThread: true, + }); + + await writeMarker(markerKey, reply.partstat); + return "emitted"; +} diff --git a/libs/rsvp-fold/src/index.ts b/libs/rsvp-fold/src/index.ts index c41d412b..ca49cbdc 100644 --- a/libs/rsvp-fold/src/index.ts +++ b/libs/rsvp-fold/src/index.ts @@ -1,3 +1,5 @@ +export { foldRsvp } from "./fold-rsvp"; + export { alreadyFolded, composeRsvpNote,