Skip to content

Fold first-time calendar invitations onto the event's thread - #379

Merged
KrisBraun merged 6 commits into
mainfrom
fold-calendar-invitations
Aug 7, 2026
Merged

Fold first-time calendar invitations onto the event's thread#379
KrisBraun merged 6 commits into
mainfrom
fold-calendar-invitations

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

A first-time calendar invitation currently imports as its own email thread even when the connector has already synced the event it refers to. The result is two threads for one meeting: the event, which renders the schedule, the guest list and the RSVP; and a mail thread carrying the calendar system's notification, which only describes those things in prose.

Attendee responses were folded onto the event thread in earlier work. Invitations were not: classifyCalendarThread bundles updates (METHOD:REQUEST with SEQUENCE > 0), cancellations and reply chains, and a bare invite falls through to a standalone thread.

What changes

A bare invitation (METHOD:REQUEST, SEQUENCE 0) is folded onto the event's thread and its note dropped, so an invitation-only conversation never becomes an email thread. A conversation that also carries real correspondence keeps its thread, minus the folded message, with its preview and classification recomputed from a message whose note survived.

This is a fold, not a bundle. Adding icaluid:<uid> to the mail link's sources would be smaller, but a bundled mail link is the event's thread: it would rewrite the event's title to Invitation: … @ … and re-author it.

The organizer's note survives. Extra content comes from the ICS COMMENT property, which RFC 5545 scopes to the iCalendar transmission rather than the event body — so it is exactly "what the invitation adds beyond the event", and the event's own DESCRIPTION can never double-post because it is never read. A stock invitation carries no COMMENT and no note is written.

An invitation is never silently lost. The fold only happens when the connector knows the event exists. The calendar sync records event-uid:<uid> for events that have guests and have not ended; the mail sync reads it through a new readCalendarState bridge, the mirror of the existing readMailState. With no marker — no calendar channel enabled, or the event lives on another provider — the email thread is kept as before.

When the mail arrives first, the email thread is kept and invite-wait:<uid> recorded; when the calendar later saves that event it archives the redundant mail link and clears the key, within a 7-day window enforced in code since the store has no native expiry.

Notes for reviewers

  • unescapeIcsText is now exported from @plotday/rsvp-fold. Connectors read escaped text out of properties that module does not parse for them, and a second copy of the RFC 5545 un-escaping rule would undo an earlier deduplication.
  • addLink does not merge sources. If an exception instance is added before its master on an incremental page, the surviving entry keeps the instance-scoped UID, so that pass marks under it and an invitation carrying the master UID will not fold. It self-heals on the next page where the master lands alone, and backfills are unaffected because instances are buffered.
  • recurrenceCount-bounded series are marked as upcoming even after they finish, because the API sets a count rather than an until. Over-inclusive in the safe direction; it only inflates the key space.
  • Known limitation: prose an organizer types into the mail body but not into the ICS is not preserved. Calendar-generated invitations never carry any, since the body is generated from the event.

Testing

libs/rsvp-fold 46 passing, connectors/google 388 passing (up from 365), tsc --noEmit clean. Roughly half the diff is tests.

Calendar fixtures use the shape the Gmail API really produces — an attachment with a synthesized invite.ics filename and a body.attachmentId, fetched through a stubbed messages.attachments.get — rather than an inline part. Both new guards were verified by breaking them and confirming the intended test fails.

🤖 Generated with Claude Code

The Google Calendar sync now records an event-uid:<iCalUID> marker for
every upcoming event with attendees it saves. The Gmail sync gains an
optional readCalendarState() bridge to check for that marker (mirroring
the existing readMailState() bridge calendar already reads on the
Google connector). Nothing consumes the new bridge yet - it lays the
groundwork for the mail sync to recognize when an arriving invitation
email already has a corresponding event thread.
A Google Calendar invitation email previously imported as its own email
thread even when the event had already synced, duplicating a thread that
already renders the schedule, guest list and RSVP affordance. First-time
invitations (ICS METHOD:REQUEST at SEQUENCE 0) are now folded onto the
event's own thread instead: the organizer's ICS COMMENT (if any) is
attached there as a note, and no separate email thread is created.
If the event hasn't synced yet, the invitation keeps its email thread and
records a pending marker so it can be reconciled once the event arrives.
Invitation updates (SEQUENCE > 0) are unaffected and continue to bundle
onto the event thread via the existing calendar-thread bundling path.
When a Google Calendar invitation email arrives before its event has synced,
the connector previously had no way to know a matching event was coming, so
it kept the invitation as its own email thread rather than risk silently
dropping it. Once the event does sync, that email thread is now redundant.
The calendar sync now checks, for every event it saves, whether there's a
pending invitation waiting on it, and if so archives that invitation's email
link so the event's own thread is the single source of truth. A pending
invitation older than 7 days is treated as stale and cleared without being
retracted, rather than lingering indefinitely.
Both sides appended to connectors/google/src/mail/gmail-api.ts: main
tagged inline images with their Content-ID, this branch added invitation
extraction. The changes are disjoint — union the imports and keep both
test describe blocks.
@KrisBraun
KrisBraun merged commit 128eb7a into mainAug 7, 2026
1 check passed
@KrisBraun
KrisBraun deleted the fold-calendar-invitations branch August 7, 2026 04:40
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