Skip to content

Fold iCloud meeting responses onto the event's thread - #370

Merged
KrisBraun merged 10 commits into
mainfrom
feat/apple-rsvp-fold
Aug 4, 2026
Merged

Fold iCloud meeting responses onto the event's thread#370
KrisBraun merged 10 commits into
mainfrom
feat/apple-rsvp-fold

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

Brings the iCloud connector to parity with the other mail connectors on attendee
responses, using the shared fold rule rather than a fourth copy of it.

What changes for a user

When someone responds to a meeting invitation, their response appears on the
event's own thread instead of arriving as a separate "Accepted:" or "Declined:"
email — except a plain acceptance, which produces no note at all.

That exception is the point. Attaching a note surfaces the thread as unread for
every recipient except the note's author, so an acceptance that only repeats what
the guest list already shows would pull the organiser's own event thread back to
unread for no new information. Declines, tentatives, acceptances carrying a
personal note, and an acceptance that reverses an earlier decline all still get a
note.

How the response is read

iCloud mailboxes receive responses generated by whatever calendar system the
responder uses, so this does not depend on any provider-specific metadata. The
text/calendar; method=REPLY part carries a structured PARTSTAT, the
responder's comment, and RECURRENCE-ID for a response to one occurrence of a
series, and @plotday/rsvp-fold — already used by the Gmail and Outlook
connectors — parses it.

No raw-MIME fetch is needed here. The platform's IMAP layer already surfaces
text/calendar and application/ics parts on MIME type alone, including the
inline multipart/alternative case that carries no filename and no attachment
disposition, so the part is fetchable by part number through the existing path.

The connector's third private copy of the iCalendar property reader is gone; it
now imports icsProp from the shared library.

Keeping a settled thread readable

Two behaviours needed care, because response notifications thread onto the
original invitation:

  • A thread root whose bundling question was already answered is no longer skipped
    wholesale. The cached bundling decision is still served unchanged — it must
    never flip — but messages that arrived since are still examined, which is what
    makes a response to an already-seen invitation reachable at all.
  • Foldedness is recorded durably per message rather than derived from whichever
    parts were read this pass. Without that the fold lasted exactly one sync: the
    next pass skipped the re-read and wrote the response back into the mail thread —
    and where the mail thread is bundled onto the event, that put the raw response
    email on the event's thread and marked it unread.

A response that has been folded away no longer contributes to the mail thread's
title, author, classification pointer, or unread state — it is not in that thread
anymore, so it should not describe it. A conversation consisting only of responses
produces no mail thread at all.

Scope note

iCloud Mail is treated here as ordinary IMAP transport carrying the same
iCalendar reply shapes other calendar systems emit; a reply generated by iCloud
Calendar itself has not been observed. Every rejection path degrades the same
way — an unrecognised or unparseable reply is left as ordinary mail with its note
intact, never dropped — so an unexpected shape costs the fold, not the message.

Tests

486 passing. The behaviours worth calling out, each verified to fail when the code
it covers is deliberately broken: a response arriving on a later sync pass than
the invitation it threads onto; a response staying folded across passes, including
on a bundled root; a redelivered response writing no second note; per-occurrence
scoping, so declining one instance of a recurring meeting does not change how a
later response to a different instance is read; and the ordering that keeps a
response's record durable before its thread is saved.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3

KrisBraunand others added 10 commits August 3, 2026 16:15
A mail thread's root is its first References entry, and calendar systems
thread response notifications onto the original invitation's Message-ID.
The bundling cache is keyed on that root and short-circuited the entire
root on a hit, so once the invitation had been ingested and its root
recorded, no later message on that thread was ever looked at again.
Split the gate. The cached bundling classification is still served
unchanged on every pass — it must never flip, including on passes where
the calendar-bearing message has aged out of the rescan window and this
pass carries no calendar part at all. What no longer short-circuits is
the per-message scan: a settled root now still fetches calendar parts it
has not read before, tracked per message in ThreadMeta.seenIcs so a part
already examined is not re-fetched on every poll.
detectCalendarBundles now returns { bundles, foldedNoteKeys }; the second
set is empty for now and exists so callers are written against the final
shape. noteKeyOf is exported from transform.ts as the shared per-message
identity both sets are keyed under.
An "Accepted: <event>" notification is an answer to an invitation, not
correspondence of its own. The iCloud mail sync now routes every
`METHOD:REPLY` calendar part through the shared response-folding rules
instead of leaving it to become an email thread beside the event.
A bare acceptance writes no note at all. That is the only way it can stop
pulling the organiser's event thread back to unread: attaching a note is
itself what surfaces a thread as unread for every recipient but its
author, and no field passed to `saveNote` suppresses that. Everything
that carries new information still gets a note on the event's thread — a
decline or a tentative, an acceptance with a personal comment, and an
acceptance that reverses an earlier decline.
Each folded response is recorded per attendee and per occurrence, so a
redelivered response is recognised rather than re-emitted, and a decline
on one instance of a recurring meeting is never mistaken for an
outstanding non-acceptance on another. Markers are flushed in one write
before the pass saves its links, so a failure there can never leave a
note on file with nothing recording it.
Two mailbox copies of one response (a folder copy alongside INBOX) are
now collapsed as the parts are read, rather than only later when the
thread's messages are deduplicated — otherwise both copies read the same
pre-pass marker and the response was emitted twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3
The rule that a response ingested from history must not mark the event
thread unread had no test: every fold test drove the pass with an empty
initial-roots set, so the branch was unreachable and replacing it with an
unconditional "unread" left the suite green.
The fold driver can now put the reply's thread root into that set, and a
test asserts the saved note carries `unread: false` — the same discipline
the mail transform already applies to the messages it ingests.
Also drops an occurrence-scoping test that could not fail. The marker key
is built by one expression used for both the read and the write, so a
single pass with a pre-seeded marker cannot detect an unscoped key: both
sides move together and the seeded marker simply stops matching. The
two-pass test that records one occurrence's decline before answering
another is the one that holds the rule, and its comment now carries that
reasoning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3
Attendee acceptances/declines that get folded onto a calendar event's
thread should not also show up as their own row in ordinary mail.
transformMessages now drops the note for any message whose key is in
the folded set, and skips emitting a link entirely when every message
in a thread was folded (otherwise the thread would surface as a
titled row with no content).
An attendee response folded onto a calendar event's thread no longer
carries a note on the mail thread, but the mail thread was still
described by whichever message came first overall. A conversation whose
earliest in-window message was a response was therefore titled
"Accepted: <event>", authored to the responder instead of to whoever
started the conversation, and pointed `signals.noteKey` at a note that
is not on the link — so body-derived classification fell back to some
other message.
Title, author and `signals.noteKey` now come from the earliest message
that still carries a note. The participant union, read state and
Sent-only rule are unchanged: a responder remains a participant of the
conversation whether or not their message is shown.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3
Two things only a full sync pass can show:
- A response is folded even when it arrives on a LATER pass than the
invitation it threads onto. Calendar systems thread response
notifications onto the invitation's Message-ID, so the response lands
on a thread root the earlier pass already classified — it is only
looked at because a settled root keeps being examined for messages it
has not read.
- Fold markers are durable before links are saved. If the link save
fails after a response note was written, the marker recording that
fold must already be on file, or the next pass re-emits the note and
drags a thread people had read back to unread.
The ordering assertion identifies the marker write by the keys it
carried: a pass makes two batched store writes, and the other one — the
per-thread metadata — is written after the save on purpose, so a
positional match would silently assert about the wrong one.
Also documents the fold in the calendar-part classifier's module doc,
which still described a `METHOD:REPLY` only as "skip".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3
Folding an attendee response removes its note from the mail thread, but
the thread's read state still counted that message as newly-arrived
unseen mail. A conversation whose only new message was a response
therefore surfaced as unread with nothing new in it — before the fold,
that unread at least arrived with the response's own note attached.
Worse on a root that bundles onto its event (an updated invitation or a
cancellation, carrying an `icaluid:` alias): there the mail link IS the
event's thread, so a bare acceptance dragged the event thread back to
unread through the link save — the very thing folding avoids on the note
side, arriving by the one route folding does not cover.
Raising unread now considers only messages that still carry a note here.
Clearing unread is unchanged and still considers every message: an
unseen response is unseen mail, so a pass that sees one makes no claim
about read state in either direction.
Also documents that a response the fold does not recognise (no ATTENDEE
line, or a participation status outside accepted/declined/tentative)
does still reach the calendar-part classifier, and tidies the fixtures
that hand-rolled ICS bodies the test helpers already build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3
A response folded onto its event's thread was dropped from the mail thread
only on the pass that read its calendar part. Because an already-examined
part is deliberately never fetched again, every later pass within the rescan
window reported nothing folded and put the response's note straight back into
the mail thread.
On a thread bundled onto its calendar event, that mail thread IS the event's
thread — so the raw response email was written onto the organiser's event
thread as an ordinary note, which marks the thread unread for everyone but
its author. On a mixed conversation the response also came back as a
duplicate of the note already on the event thread, and took over the mail
thread's title and author again.
Foldedness is now durable per thread root (`ThreadMeta.foldedIcs`) and is
seeded before any of the pass's short-circuits, so a root whose bundling
decision is cached and whose messages have all been examined still reports
what it folded. It is retained in lockstep with the examined-part list rather
than under a cap of its own: a key can only be dropped alongside the entry
that keeps its message off the wire, so the next pass re-reads and re-folds
the message instead of resurfacing its note.
Also flush the fold markers in a `finally`. Batching them into one write made
them all-or-nothing: a note write that failed part-way through a pass took
the markers of every response already written with it, and the next pass
re-sent exactly those notes and raised unread again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3
The case sat under the suite that covers how calendar properties are read,
but it cannot detect a regression there: a property reader that returned
nothing would short-circuit on the missing UID and produce the same null.
Moved to the classification matrix, where the verdict it checks is the point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GcnBUEdW86ovpv1pT2d3b3
@KrisBraun
KrisBraun merged commit 466f0d3 into mainAug 4, 2026
1 check passed
@KrisBraun
KrisBraun deleted the feat/apple-rsvp-fold branch August 4, 2026 01:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KrisBraun