Uh oh!
There was an error while loading. Please reload this page.
Fold iCloud meeting responses onto the event's thread - #370
Merged
Conversation
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
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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=REPLYpart carries a structuredPARTSTAT, theresponder's comment, and
RECURRENCE-IDfor a response to one occurrence of aseries, and
@plotday/rsvp-fold— already used by the Gmail and Outlookconnectors — parses it.
No raw-MIME fetch is needed here. The platform's IMAP layer already surfaces
text/calendarandapplication/icsparts on MIME type alone, including theinline
multipart/alternativecase that carries no filename and no attachmentdisposition, 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
icsPropfrom the shared library.Keeping a settled thread readable
Two behaviours needed care, because response notifications thread onto the
original invitation:
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.
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