Skip to content

Commit bcefcd3

Browse files
KrisBraunclaude
andcommitted
feat(rsvp-fold): own the fold sequence in a shared foldRsvp() helper
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3
1 parent 7f16510 commit bcefcd3

7 files changed

Lines changed: 536 additions & 208 deletions

File tree

‎connectors/apple/src/mail/sync.ts‎

Lines changed: 38 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,4 @@
1-
import {
2-
alreadyFolded,
3-
composeRsvpNote,
4-
icsProp,
5-
isNonAcceptance,
6-
parseIcsReply,
7-
priorRsvpKey,
8-
shouldEmitRsvpNote,
9-
} from "@plotday/rsvp-fold";
1+
import { foldRsvp, icsProp, parseIcsReply } from "@plotday/rsvp-fold";
102
import type { ActorId } from "@plotday/twister";
113
import type { ImapMailboxStatus, ImapSession } from "@plotday/twister/tools/imap";
124

@@ -90,10 +82,11 @@ export type ThreadMeta = {
9082
* must never flip: this is per-MESSAGE, and exists so a root whose bundling
9183
* question is already settled still looks at messages that arrived since.
9284
*
93-
* Required, not an optimisation. `alreadyFolded` stops a duplicate note but
94-
* not the IMAP fetch, and inside the 30-day rescan window an RSVP would
95-
* otherwise be re-fetched on every pass — roughly 2,900 times, at up to two
96-
* round-trips each, against a ~1,000-request execution budget.
85+
* Required, not an optimisation. `foldRsvp`'s repeat check stops a
86+
* duplicate note but not the IMAP fetch, and inside the 30-day rescan
87+
* window an RSVP would otherwise be re-fetched on every pass — roughly
88+
* 2,900 times, at up to two round-trips each, against a ~1,000-request
89+
* execution budget.
9790
*
9891
* Capped at SEEN_ICS_MAX, oldest dropped. Growth is bounded in practice by
9992
* how many responses one meeting draws, but this document is rewritten every
@@ -121,9 +114,10 @@ export type ThreadMeta = {
121114
* key can only be dropped together with its `seenIcs` entry, which puts the
122115
* message back among the unexamined and has the next pass re-fetch and
123116
* re-fold it. (The response's own `rsvp:` marker is a separate, uncapped
124-
* key, so `alreadyFolded` still suppresses a second note.) An independent
125-
* cap would have no such backstop — it would drop a folded key while the
126-
* message stayed un-fetched, and the note would resurface permanently.
117+
* key, so `foldRsvp` still recognises the repeat and writes no second
118+
* note.) An independent cap would have no such backstop — it would drop a
119+
* folded key while the message stayed un-fetched, and the note would
120+
* resurface permanently.
127121
*/
128122
foldedIcs?: string[];
129123
};
@@ -423,9 +417,11 @@ export async function detectCalendarBundles(
423417
// `dedupeCopies` only collapses them later, inside `transformMessages` —
424418
// `unexamined` was filtered against a snapshot of `seen`, so both copies
425419
// are in it. The second copy has nothing new to read, and routing it
426-
// again would emit its response note twice: the marker written below is
427-
// flushed once at the end of the pass, so the second copy would still
428-
// read the pre-pass value and look un-folded.
420+
// again would emit its response note twice: the marker is collected
421+
// rather than written through (see `writeMarker` below) and flushed
422+
// once at the end of the pass, so the second copy would still read the
423+
// pre-pass value and look un-folded. This guard is the in-pass dedup
424+
// `foldRsvp` requires of any connector that batches its markers.
429425
if (seen.has(noteKeyOf(m))) continue;
430426

431427
const part = (m.attachments ?? []).find((a) => isCalendarAttachment(a.mimeType))!;
@@ -449,50 +445,31 @@ export async function detectCalendarBundles(
449445
// reply is not an answer to the bundling question.
450446
const replyUid = icsProp(ics, "UID");
451447
if (replyUid) {
452-
const priorKey = priorRsvpKey(replyUid, reply.attendeeEmail, reply.occurrence);
453-
const stored = await host.get<string>(priorKey);
454-
455-
// Order is the library's documented contract: `alreadyFolded`
456-
// FIRST, and only when it is false decide whether to emit.
457-
// Re-emitting a note the thread already carries re-applies its
458-
// unread intent and drags the thread back to unread for everyone
459-
// who had read it — and a response inside the 30-day rescan window
460-
// is re-read on every pass.
461-
if (
462-
!alreadyFolded(stored, reply) &&
463-
shouldEmitRsvpNote(reply, isNonAcceptance(stored))
464-
) {
465-
// `saveNote` returns null when no thread carries `icaluid:<uid>`
466-
// yet (the calendar event has not synced); `deferUntilThread` has
467-
// the platform hold the note and attach it once that thread
468-
// appears.
469-
await host.integrations.saveNote({
470-
thread: { source: `icaluid:${replyUid}` },
448+
// The decision order (repeat check first, marker written only on
449+
// the emitting path) and the reasoning behind it live once, on
450+
// `foldRsvp`. Only what is iCloud-specific stays here — including
451+
// the batched marker write below, which this connector needs
452+
// because a response inside the 30-day rescan window would
453+
// otherwise cost a store request per response per pass.
454+
await foldRsvp({
455+
uid: replyUid,
456+
reply,
457+
// `created` may be absent here — `foldRsvp` leaves the field
458+
// off the note rather than sending an explicit undefined.
459+
note: {
471460
key: noteKeyOf(m),
472-
content: composeRsvpNote(reply),
473-
contentType: "markdown",
474-
...(m.date ? { created: m.date } : {}),
475-
author: {
476-
email: reply.attendeeEmail,
477-
...(reply.attendeeName ? { name: reply.attendeeName } : {}),
478-
},
479-
// Explicit on both paths. An omitted flag does NOT mean "leave
480-
// read state alone" — attaching a note already marks the thread
481-
// unread for every recipient except its author, so only an
482-
// explicit false overrides it.
461+
created: m.date,
483462
unread: !initialRoots.has(root),
484-
deferUntilThread: true,
485-
});
486-
// Recorded ONLY on the path that emits, and regardless of the
487-
// return value: a deferred note returns no id, and gating on it
488-
// would leave a deferred non-acceptance unrecorded forever,
489-
// wrongly treating a later bare acceptance as reversing nothing.
490-
// The marker holds the last response actually folded onto the
491-
// thread — for every emitted response, acceptances included,
492-
// which is what lets `alreadyFolded` recognise a repeat of ANY
493-
// partstat.
494-
rsvpMarkers.push([priorKey, reply.partstat]);
495-
}
463+
},
464+
readMarker: (key) => host.get<string>(key),
465+
// Collected, not written: flushed in ONE `setMany` in the
466+
// `finally` below, so a `saveNote` that throws later in the pass
467+
// cannot take the markers of responses already written with it.
468+
writeMarker: (key, partstat) => {
469+
rsvpMarkers.push([key, partstat]);
470+
},
471+
saveNote: (note) => host.integrations.saveNote(note),
472+
});
496473

497474
// Folded whether or not a note was written — a bare acceptance is
498475
// dropped from the mail thread rather than left to become an email

‎connectors/google/src/mail/sync.ts‎

Lines changed: 23 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,7 @@
1717
* returns a descriptor and lets the caller own the scheduling.
1818
*/
1919
import { enrichLinkContactsFromGoogle } from "@plotday/google-contacts";
20-
import {
21-
alreadyFolded,
22-
composeRsvpNote,
23-
isNonAcceptance,
24-
priorRsvpKey,
25-
shouldEmitRsvpNote,
26-
} from "@plotday/rsvp-fold";
20+
import { foldRsvp } from "@plotday/rsvp-fold";
2721
import {
2822
baseEmail,
2923
canonicalizeEmail,
@@ -1562,65 +1556,30 @@ async function saveTransformedThread(
15621556
const replies = extractCalendarReplies(thread.messages ?? [], icsByMessage);
15631557
if (replies.length > 0) {
15641558
for (const reply of replies) {
1565-
const priorKey = priorRsvpKey(reply.uid, reply.attendeeEmail, reply.occurrence);
1566-
// Read on every response, not just a bare acceptance: `alreadyFolded`
1567-
// needs the stored value on every path, so there is no cheaper way to
1568-
// skip this round-trip anymore (there used to be one for the
1569-
// non-acceptance/commented-acceptance cases — traded away below).
1570-
const stored = await host.get<string>(priorKey);
1571-
1572-
// Re-processing a conversation re-runs this loop for a response
1573-
// already folded onto the event thread — Gmail history replay, a
1574-
// backfill overlap, at-least-once delivery. The note itself upserts
1575-
// by key, so re-saving it wouldn't duplicate it, but its `unread`
1576-
// intent would still be re-applied and drag the thread back to
1577-
// unread for anyone who already read it. Comparing against the
1578-
// stored partstat (not just presence) means a genuine change of
1579-
// response is never caught by this: an attendee who edits only their
1580-
// comment on an unchanged response gets no updated note, which is
1581-
// the accepted trade for not re-raising unread on every re-deliver.
1582-
if (alreadyFolded(stored, reply)) {
1583-
foldedMessageIds.add(reply.messageId);
1584-
continue;
1585-
}
1586-
1587-
// A bare acceptance says nothing the event's guest list does not
1588-
// already show. Drop the message rather than writing a note: a note
1589-
// is the only thing that could mark the organiser's thread unread,
1590-
// and marking it folded here keeps the responses-only conversation
1591-
// from becoming an email thread of its own.
1592-
if (!shouldEmitRsvpNote(reply, isNonAcceptance(stored))) {
1593-
foldedMessageIds.add(reply.messageId);
1594-
continue;
1595-
}
1596-
1597-
// saveNote returns null when no thread carries `icaluid:<uid>` yet —
1598-
// the calendar event has not synced. deferUntilThread has the
1599-
// platform hold the note and attach it once that thread appears,
1600-
// instead of us leaving it in the email thread as a fallback. The
1601-
// return value is not consulted: null now covers both "parked" and,
1602-
// in principle, "genuinely rejected", and deferUntilThread means we
1603-
// no longer need to tell those apart.
1604-
await host.tools.integrations.saveNote({
1605-
thread: { source: `icaluid:${reply.uid}` },
1606-
key: reply.messageId,
1607-
content: composeRsvpNote(reply),
1608-
contentType: "markdown",
1609-
created: reply.sourceCreatedAt,
1610-
author: {
1611-
email: reply.attendeeEmail,
1612-
...(reply.attendeeName ? { name: reply.attendeeName } : {}),
1559+
// The decision order (repeat check first, marker written only on the
1560+
// emitting path) and the reasoning behind it live once, on
1561+
// `foldRsvp`. Only what is Gmail-specific stays here.
1562+
await foldRsvp({
1563+
uid: reply.uid,
1564+
reply,
1565+
note: {
1566+
key: reply.messageId,
1567+
created: reply.sourceCreatedAt,
1568+
unread: !initialSync,
16131569
},
1614-
// Explicit on both paths. An omitted flag does NOT mean "leave read
1615-
// state alone": attaching a note already surfaces the thread as
1616-
// unread for every recipient except its author, so only an explicit
1617-
// false overrides it.
1618-
unread: !initialSync,
1619-
deferUntilThread: true,
1570+
readMarker: (key) => host.get<string>(key),
1571+
writeMarker: (key, partstat) => host.set(key, partstat),
1572+
saveNote: (note) => host.tools.integrations.saveNote(note),
16201573
});
1621-
// Folded either way. A miss is now held by the platform rather than
1622-
// returned to us, so a responses-only conversation never creates an
1623-
// email thread even when the event is missing — the note is
1574+
1575+
// Folded on every outcome — emitted, suppressed, or already folded.
1576+
// A response never stays in the email thread: dropping it is what
1577+
// keeps a responses-only conversation from becoming an email thread
1578+
// of its own, and a suppressed bare acceptance is folded precisely
1579+
// because writing no note is the right answer for it.
1580+
//
1581+
// A note whose event has not synced is held by the platform rather
1582+
// than returned to us, so this holds even on a miss — the note is
16241583
// guaranteed to land eventually, but only if the sweep that attaches
16251584
// parked notes keeps working. A regression there loses the response
16261585
// silently instead of leaving it visibly stranded in the inbox.
@@ -1637,20 +1596,6 @@ async function saveTransformedThread(
16371596
// that email thread for responses-only conversations even at the
16381597
// cost of losing the response outright for calendar-less recipients.
16391598
foldedMessageIds.add(reply.messageId);
1640-
// Recorded regardless of `noteId`: the decision to emit is what this
1641-
// bookkeeping tracks, and with deferUntilThread the platform now
1642-
// guarantees the note lands eventually even on a miss. Previously
1643-
// this only ran on an immediate attach, with the drain's own retry
1644-
// recording it later on success — with the drain gone, gating on
1645-
// `noteId` here would leave a deferred non-acceptance unrecorded
1646-
// forever, wrongly treating a later bare acceptance as reversing
1647-
// nothing.
1648-
//
1649-
// Always set, never cleared: the key now holds the last response
1650-
// actually folded, for every emitted response (including an
1651-
// acceptance) — that's what lets `alreadyFolded` recognise a repeat
1652-
// of ANY partstat, not just an outstanding non-acceptance.
1653-
await host.set(priorKey, reply.partstat);
16541599
}
16551600

16561601
if (foldedMessageIds.size > 0) {

‎connectors/outlook/src/mail/sync.ts‎

Lines changed: 16 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,7 @@ import type {
3737
} from "@plotday/twister/plot";
3838
import type { WebhookRequest } from "@plotday/twister/tools/network";
3939
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";
40-
import {
41-
alreadyFolded,
42-
composeRsvpNote,
43-
isNonAcceptance,
44-
priorRsvpKey,
45-
shouldEmitRsvpNote,
46-
} from "@plotday/rsvp-fold";
40+
import { foldRsvp } from "@plotday/rsvp-fold";
4741

4842
import { enrichLinkContactsFromOutlook } from "./enrich";
4943
import {
@@ -1550,70 +1544,23 @@ export async function processConversationsFn(
15501544
if (!reply) continue;
15511545

15521546
const noteKey = m.internetMessageId ?? m.id;
1553-
const priorKey = priorRsvpKey(uid, reply.attendeeEmail, reply.occurrence);
1554-
// Read on every response, not just a bare acceptance: `alreadyFolded`
1555-
// needs the stored value on every path, so there is no cheaper way to
1556-
// skip this round-trip anymore (there used to be one for the
1557-
// non-acceptance/commented-acceptance cases — traded away below).
1558-
const stored = await host.get<string>(priorKey);
1559-
1560-
// Re-processing a conversation re-runs this loop for a response
1561-
// already folded onto the event thread — Graph's subscription fires
1562-
// on `updated` as well as `created`, and the drain re-fetches the
1563-
// whole conversation for any notified message. The note itself
1564-
// upserts by key, so re-saving it wouldn't duplicate it, but its
1565-
// `unread` intent would still be re-applied and drag the thread back
1566-
// to unread for anyone who already read it. Comparing against the
1567-
// stored partstat (not just presence) means a genuine change of
1568-
// response is never caught by this: an attendee who edits only their
1569-
// comment on an unchanged response gets no updated note, which is
1570-
// the accepted trade for not re-raising unread on every re-deliver.
1571-
if (alreadyFolded(stored, reply)) {
1572-
foldedMessageIds.add(noteKey);
1573-
continue;
1574-
}
1575-
1576-
// A bare acceptance says nothing the event's guest list does not
1577-
// already show. Drop the message rather than writing a note: a note
1578-
// is the only thing that could mark the organiser's thread unread,
1579-
// and marking it folded here keeps a responses-only conversation
1580-
// from becoming an email thread of its own.
1581-
if (!shouldEmitRsvpNote(reply, isNonAcceptance(stored))) {
1582-
foldedMessageIds.add(noteKey);
1583-
continue;
1584-
}
1585-
1586-
// saveNote returns null when no thread carries `icaluid:<uid>` yet
1587-
// (the calendar event hasn't synced). deferUntilThread has the
1588-
// platform hold the note and attach it once that thread appears.
1589-
await host.tools.integrations.saveNote({
1590-
thread: { source: `icaluid:${uid}` },
1591-
key: noteKey,
1592-
content: composeRsvpNote(reply),
1593-
contentType: "markdown",
1594-
created: messageDate(m),
1595-
author: {
1596-
email: reply.attendeeEmail,
1597-
...(reply.attendeeName ? { name: reply.attendeeName } : {}),
1598-
},
1599-
// Explicit on both paths. An omitted flag does NOT mean "leave read
1600-
// state alone": attaching a note already surfaces the thread as
1601-
// unread for every recipient except its author, so only an
1602-
// explicit false overrides it.
1603-
unread: !initialSync,
1604-
deferUntilThread: true,
1547+
// The decision order (repeat check first, marker written only on the
1548+
// emitting path) and the reasoning behind it live once, on
1549+
// `foldRsvp`. Only what is Graph-specific stays here.
1550+
await foldRsvp({
1551+
uid,
1552+
reply,
1553+
note: { key: noteKey, created: messageDate(m), unread: !initialSync },
1554+
readMarker: (key) => host.get<string>(key),
1555+
writeMarker: (key, partstat) => host.set(key, partstat),
1556+
saveNote: (note) => host.tools.integrations.saveNote(note),
16051557
});
1558+
// Folded on every outcome — emitted, suppressed, or already folded.
1559+
// A response never stays in the mail thread: dropping it is what
1560+
// keeps a responses-only conversation from becoming an email thread
1561+
// of its own, and a suppressed bare acceptance is folded precisely
1562+
// because writing no note is the right answer for it.
16061563
foldedMessageIds.add(noteKey);
1607-
// Recorded regardless of the saveNote return value: a deferred note
1608-
// returns no id, and gating on it would leave a deferred
1609-
// non-acceptance unrecorded forever, wrongly treating a later bare
1610-
// acceptance as reversing nothing.
1611-
//
1612-
// Always set, never cleared: the key now holds the last response
1613-
// actually folded, for every emitted response (including an
1614-
// acceptance) — that's what lets `alreadyFolded` recognise a repeat
1615-
// of ANY partstat, not just an outstanding non-acceptance.
1616-
await host.set(priorKey, reply.partstat);
16171564
}
16181565
// Messages whose note survived the fold — everything below that reads
16191566
// `item.messages` to pick a "parent" (facets, calendar bundling) must

‎libs/rsvp-fold/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ tentative) onto the event's thread as a note.
55

66
## What it does
77

8+
- `foldRsvp({ uid, reply, note, readMarker, writeMarker, saveNote })` — folds
9+
one response onto its event's thread, owning the order the rules below have
10+
to be applied in: check for a repeat first, only then decide whether the
11+
response earns a note, and record it only when one was actually written.
12+
Getting that order wrong re-raises unread on threads people have already
13+
read, so connectors call this rather than sequencing the predicates
14+
themselves. Reading and writing the marker, and saving the note, are
15+
injected — connectors differ in how they batch those.
816
- `composeRsvpNote(reply)` — formats the one-line note (plus the responder's
917
personal comment, when they left one) that a connector attaches to the
1018
event thread.

0 commit comments

Comments
 (0)