Skip to content

Fold Outlook meeting responses onto the event's thread - #368

Merged
KrisBraun merged 15 commits into
mainfrom
feat/outlook-rsvp-fold
Aug 3, 2026
Merged

Fold Outlook meeting responses onto the event's thread#368
KrisBraun merged 15 commits into
mainfrom
feat/outlook-rsvp-fold

Conversation

@KrisBraun

Copy link
Copy Markdown
Contributor

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=REPLY part with a structured PARTSTAT, the
responder's comment, and RECURRENCE-ID for a response to one occurrence of
a 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 .ics attachment at all — its calendar data is an alternative
part — so an attachments-based reader would miss every one of them. The two
systems also spell the responder's note differently (COMMENT property vs an
X-RESPONSE-COMMENT parameter); the shared parser reads both.

Graph's own meetingMessageType is used only as a cheap pre-filter to decide
which 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 scoped
per 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), so
this 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.

KrisBraunand others added 15 commits August 3, 2026 10:04
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
@KrisBraun
KrisBraun merged commit 4d682ee into mainAug 3, 2026
1 check passed
@KrisBraun
KrisBraun deleted the feat/outlook-rsvp-fold branch August 3, 2026 18:15
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