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,