Uh oh!
There was an error while loading. Please reload this page.
Fold Outlook meeting responses onto the event's thread - #368
Merged
Conversation
Google Calendar and Outlook Calendar both need to fold an attendee's RSVP onto the event's thread using the same rule: a bare acceptance is suppressed (it repeats what the guest list already shows and would otherwise mark the thread unread for no new information), while a decline, a tentative, a commented acceptance, or an acceptance that reverses an earlier non-acceptance all earn a note. Since connectors can't import from one another, this rule moves out of the Gmail connector and into a new shared `@plotday/rsvp-fold` package under `libs/`, so both calendar connectors can share one implementation and one set of tests. The Gmail connector now consumes it via `@plotday/rsvp-fold` instead of a local module; its own behavior and tests are unchanged.
An unparseable originalStart on an occurrence/exception event previously produced an Invalid Date, which crashes downstream at priorRsvpKey's occurrence.toISOString() call. Parse it the same way parseIcsDate already does for the Gmail connector: validate the parsed epoch and fall back to null (a series-scoped key) instead of an uncaught RangeError.
Both Google Calendar and Microsoft Exchange notify an organizer of an attendee's accept/decline/tentative response with the same `METHOD:REPLY` iCalendar shape, differing only in where the responder's personal note lives (a standard `COMMENT` property vs. an `X-RESPONSE-COMMENT` parameter on the `ATTENDEE` line). Moves that per-ICS parse into `@plotday/rsvp-fold` as `parseIcsReply`, so any calendar connector can read a reply without re-implementing the format, and Gmail now delegates to it instead of carrying its own copy.
parseIcsReply no longer falls back to the caller's address when an ATTENDEE line has no resolvable email — a reply with no address isn't from anyone in particular, so it's dropped rather than attributed to whoever merely delivered the notification. The fallback now carries only a display name, matching what Gmail's extractor always did before this parser was shared. Also exports icsProp from the library and has Gmail import it instead of keeping a private copy, so the two connectors' METHOD/UID/PARTSTAT lookups can't silently drift apart.
Microsoft-generated meeting replies carry a text/calendar; method=REPLY part but no application/ics attachment, so reading only attachments would silently miss every one of them. extractOutlookReply instead reads the message's raw MIME (a new GraphMailApi.getMimeContent, GET .../$value) and locates the calendar part directly, preferring text/calendar over a duplicate application/ics attachment when both are present, and decoding either 7bit or base64 transfer encoding before handing the ICS text to the shared parseIcsReply parser. Also simplifies classifyOutlookCalendar back to a cheap partstat-only pre-filter: the occurrence an RSVP responds to is now read from the reply's own ICS (RECURRENCE-ID) rather than trusted from Graph's event.originalStart/type, so the $expand at both call sites narrows back to iCalUId only.
getMimeContent's only test stubbed call() itself, which meant it never ran call()'s new "return raw text, skip JSON.parse" branch — a regression there (or in threading raw:true through to call()) would have left the suite green. Replaced it with a mocked fetch so the real call() implementation runs against a MIME body that is deliberately not valid JSON; also added a 404-returns-null case. Also added two malformed-MIME cases (no boundary= parameter; a truncated body with no closing delimiter) proving extractOutlookReply degrades to null instead of throwing, and a one-line doc note on extractOutlookReply explaining why fallback.email is accepted but currently unused.
…bundling/facets resolveBatchRsvpMimeFn now catches a per-message Graph failure (5xx, a mid-batch 401, a retry-exhausted 429/503) instead of letting it escape the batch — one bad message now degrades to ordinary mail instead of aborting every conversation in the pass. The fold's surviving-message set is now also used for classifyOutlookCalendar and the facet/preview "parent" pick, so a folded RSVP message can no longer (a) get the rest of a mixed conversation bundled onto the calendar event's thread via a shared `sources` element, or (b) supply a stale `signals.noteKey` or thread preview pointing at a note that no longer exists.
Three fixes from the whole-branch review: - A reply that could not be parsed (no MIME, no calendar part, unreadable ICS) was still bundled onto the event's thread via its icaluid source, putting the notification email itself in front of the organiser. The rsvp classification is a pre-filter only and never contributes a source now; cancel and update still bundle as before. - Base64 calendar parts were decoded as Latin-1, so accented and non-Latin names and comments arrived mangled. They are decoded as UTF-8 now, matching the other connector. - A folded reply still drove the surviving conversation's unread and flagged state, leaving a thread marked unread with nothing unread left in it. Also corrects the NewNote.unread documentation: omitting it does not leave read state alone, and a connector that needs a note to avoid creating unread must pass an explicit false.
The Gmail and Outlook connectors already avoided writing a duplicate note when an attendee's calendar response was processed twice, but each re-processed response still re-applied the note's unread flag — pulling the event thread back to unread for anyone who had already read it, even though nothing new was said. Both providers routinely redeliver the same message (a mail subscription firing on "updated" as well as "created", a sync replay), so this was a routine occurrence rather than a rare edge case. Add `alreadyFolded` and `isNonAcceptance` to `@plotday/rsvp-fold` so a connector can tell an incoming response that repeats what it already folded onto the event thread apart from a genuine change of response (e.g. a decline followed later by an acceptance, which still gets its own note). Both connectors now compare every incoming response against the last one they folded, and record every emitted response — not just outstanding declines/tentatives — so the comparison works for every response type, including a repeated acceptance. One accepted trade-off: an attendee who edits only their personal note without changing their response gets no updated note, since the response itself looks unchanged. Also removes an Outlook test that pinned an optimisation (skipping the prior-state read for most responses) that is no longer possible once every response needs the prior value for this comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EojBeTMfriEnuDHpRfRh9x
…er a repeated acceptance priorRsvpKey's doc comment said a bare acceptance always leaves the marker unset, which isn't true when that acceptance reverses a prior decline or tentative — that path does emit a note and does store "ACCEPTED", which is exactly what lets a later repeat of it be recognised by alreadyFolded. Restate the real condition and add a one-line call-order note (read the marker, check alreadyFolded, then shouldEmitRsvpNote, write back only on the emit path) so a connector implementer has the sequence spelled out rather than having to infer it from two existing connectors. Also extend each connector's reversal test to re-deliver the same acceptance a third time and assert no third note is written — the sequence the old always-clear-on-accept store handled incorrectly, and one this suite previously only exercised for a repeated decline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EojBeTMfriEnuDHpRfRh9x
…nectors A comment-less acceptance redelivery is already suppressed by shouldEmitRsvpNote on its own once there's no outstanding non-acceptance — alreadyFolded never has to matter for that shape. A commented acceptance is the one response shouldEmitRsvpNote always says yes to (any comment earns a note), so alreadyFolded is the only thing standing between a redelivered commented acceptance and re-emitting a note — and an unread flip — on every redelivery. Add a test per connector that folds a commented acceptance, redelivers the identical message, and asserts no second note is written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EojBeTMfriEnuDHpRfRh9x
The branch corrects a doc comment that stated omitting `unread` leaves a thread's read state alone. It does not, and authors relying on that reading ship notes that silently mark threads unread. Record it in the changelog so the correction reaches consumers. 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 Outlook to parity with Gmail on attendee responses, and shares the
rule between them so they cannot drift.
What changes for a user
When someone responds to a meeting invitation, their response is folded onto
the event's thread instead of arriving as a separate email thread — 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 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
Outlook mailboxes receive responses generated by whatever calendar system the
responder uses, so this does not rely on Microsoft-specific message metadata.
Both Google- and Exchange-generated replies carry a
text/calendar; method=REPLYpart with a structuredPARTSTAT, theresponder's comment, and
RECURRENCE-IDfor a response to one occurrence ofa series. The connector fetches the message's raw MIME and parses that part
with the same parser the Gmail connector already uses.
Raw MIME rather than attachments is load-bearing: an Exchange-generated reply
carries no
.icsattachment at all — its calendar data is an alternativepart — so an attachments-based reader would miss every one of them. The two
systems also spell the responder's note differently (
COMMENTproperty vs anX-RESPONSE-COMMENTparameter); the shared parser reads both.Graph's own
meetingMessageTypeis used only as a cheap pre-filter to decidewhich messages are worth fetching MIME for.
Shared library
The fold rule, the storage-key convention and the iCalendar reply parser move
into
@plotday/rsvp-fold, consumed by both connectors. The dedup key is scopedper occurrence, so declining one instance of a recurring meeting does not change
how a later response to a different instance is treated.
Also changes Gmail
Both connectors now compare an incoming response against the last one they
folded and skip a repeat. Previously a re-processed response re-applied the
note's unread flag — the note itself upserted, but the thread was pulled back
to unread for anyone who had already read it. Providers redeliver the same
message routinely (a mail subscription firing on
updated, a sync replay), sothis happened in ordinary use rather than as an edge case.
One accepted trade-off: an attendee who edits only their personal note without
changing their response gets no updated note, since the response itself looks
unchanged.
Worth watching after deploy
The pre-filter requires Graph to expose an event link and a meeting-message
type on the response. Whether it does so for a response generated by a
non-Microsoft calendar system is unverified — if it exposes neither, those
responses are not folded and stay as ordinary mail. That is the pre-existing
behaviour rather than a regression, but it is worth measuring rather than
assuming.
Verification
Shared library 33 tests; Gmail connector 359, including its full pre-existing
suite unchanged; Outlook connector 143. Type checks clean across all three.
Test fixtures are modelled on real messages from both calendar systems, with
identities replaced.