From 0cee4223730879e10f1784b726b1031b2ef3d47a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:20:23 +0200 Subject: [PATCH] fix: stop reloading an invitation when the message is marked read The invitation banner loaded the .ics whenever it was handed a new message object, so marking an invitation read or starring it downloaded and parsed the calendar part again (and fetched its raw text when the method had to be read from it). It now loads once per message, account and calendar part (`calendarInvitationKey`), and reads the current message only when the load runs. --- .../email/CalendarInvitationBanner.tsx | 19 ++++++++++----- src/lib/__tests__/calendar-invitation.test.ts | 23 +++++++++++++++++++ src/lib/calendar-invitation.ts | 14 +++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 4479c36f..e4fcaca9 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -14,6 +14,7 @@ import { useCalendarStore } from '../../stores/calendar-store'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; import { + calendarInvitationKey, findCalendarAttachment, findParticipantByEmail, getInvitationMethod, @@ -72,6 +73,11 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { // Login address + identities + aliases, so invitations addressed to an // alias still show the RSVP buttons. Only an invitation looks the aliases up. const currentUserEmails = useUserCalendarAddresses(!!attachment && enabled); + // The invitation is loaded once per message, account and calendar part; + // a mark-read or star hands a new `email` for the same one. + const invitationKey = calendarInvitationKey(email, attachment, jmapAccountId); + const latest = React.useRef({ email, attachment }); + latest.current = { email, attachment }; const [state, setState] = React.useState('loading'); const [event, setEvent] = React.useState | null>(null); @@ -87,12 +93,13 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { React.useEffect(() => { let cancelled = false; - if (!attachment || !enabled) return; + const { email: current, attachment: part } = latest.current; + if (!invitationKey || !part || !enabled) return; setState('loading'); setServerMatch(null); (async () => { try { - const events = await parseCalendarBlob(attachment.blobId, jmapAccountId); + const events = await parseCalendarBlob(part.blobId, jmapAccountId); if (cancelled) return; if (events.length === 0) { setState('error'); @@ -110,11 +117,11 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { } // Explicit method (Content-Type params) first; JMAP usually strips // them, so fall back to the raw ICS METHOD line before guessing. - let detected = getInvitationMethod(parsed, { email, attachment }); + let detected = getInvitationMethod(parsed, { email: current, attachment: part }); if (detected === 'unknown') { - const raw = await fetchCalendarBlobText(attachment.blobId, jmapAccountId); + const raw = await fetchCalendarBlobText(part.blobId, jmapAccountId); if (cancelled) return; - detected = getInvitationMethod(parsed, { email, attachment, rawIcs: raw }); + detected = getInvitationMethod(parsed, { email: current, attachment: part, rawIcs: raw }); } setMethod(detected); setState('parsed'); @@ -123,7 +130,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { } })(); return () => { cancelled = true; }; - }, [attachment, enabled, email, jmapAccountId]); + }, [invitationKey, enabled, jmapAccountId]); // Import into the account's default calendar; never into a shared calendar, // an iCal subscription (the next feed sync would delete the event) or a diff --git a/src/lib/__tests__/calendar-invitation.test.ts b/src/lib/__tests__/calendar-invitation.test.ts index a8ba1c27..ab1b65c7 100644 --- a/src/lib/__tests__/calendar-invitation.test.ts +++ b/src/lib/__tests__/calendar-invitation.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import type { Email } from '../../api/types'; import { + calendarInvitationKey, extractMethodFromContentType, extractMethodFromRawIcs, findCalendarAttachment, @@ -113,3 +114,25 @@ describe('getInvitationTrustAssessment', () => { expect(getInvitationTrustAssessment(request, e, 'unknown').level).toBe('trusted'); }); }); + +describe('calendarInvitationKey', () => { + const invite = { + id: 'e1', + keywords: {}, + attachments: [{ blobId: 'b1', type: 'text/calendar', name: 'invite.ics', size: 10 }], + } as unknown as Email; + + it('stays the same when only the keywords change', () => { + const read = { ...invite, keywords: { $seen: true } } as Email; + const key = calendarInvitationKey(invite, findCalendarAttachment(invite), 'group'); + expect(key).toBe('group|e1|b1'); + expect(calendarInvitationKey(read, findCalendarAttachment(read), 'group')).toBe(key); + }); + + it('differs per account and calendar part, and is null without one', () => { + const other = { ...invite, attachments: [{ ...invite.attachments![0], blobId: 'b2' }] } as Email; + expect(calendarInvitationKey(invite, findCalendarAttachment(invite))).toBe('|e1|b1'); + expect(calendarInvitationKey(other, findCalendarAttachment(other))).toBe('|e1|b2'); + expect(calendarInvitationKey({ id: 'e2' }, null)).toBeNull(); + }); +}); diff --git a/src/lib/calendar-invitation.ts b/src/lib/calendar-invitation.ts index ea3657a7..b5bdee51 100644 --- a/src/lib/calendar-invitation.ts +++ b/src/lib/calendar-invitation.ts @@ -250,6 +250,20 @@ export function findCalendarAttachment( return findCalendarBodyPart(email.textBody) || findCalendarBodyPart(email.htmlBody); } +/** + * What identifies a message's invitation for loading it: the message, its + * account and the calendar part's blob. A keyword change (read, star) hands + * a new message object with the same key, and must not download and parse + * the .ics again. Null when the message has no calendar part. + */ +export function calendarInvitationKey( + email: Pick, + attachment: Pick | null, + jmapAccountId?: string, +): string | null { + return attachment ? `${jmapAccountId ?? ''}|${email.id}|${attachment.blobId}` : null; +} + // ─── Authentication-Results / trust ────────────────────── export interface AuthenticationResults {