From 3472bc0419cceb5f1c7d7dd5287a5ac97bd4b8aa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:36:04 +0200 Subject: [PATCH] fix: parse calendar invitations in directly viewed shared folders against the folder owner #867 --- .../calendar-invitation-banner.test.tsx | 36 ++++++++++++++-- .../email/calendar-invitation-banner.tsx | 13 +++++- lib/__tests__/email-date.test.ts | 42 ++++++++++++++++++ lib/email-date.ts | 43 +++++++++++++++++++ 4 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/email-date.test.ts create mode 100644 lib/email-date.ts diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index de6be7fcf..b45a8b883 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => { const pushMock = vi.fn(); const clientMock = { parseCalendarEvents: vi.fn(), + getAccountId: vi.fn(() => 'mail-account'), getCalendarsAccountId: vi.fn(() => 'calendar-account'), getCalendarEvent: vi.fn(), queryCalendarEvents: vi.fn(async () => []), @@ -15,6 +16,7 @@ const mocks = vi.hoisted(() => { const sourceClientMock = { parseCalendarEvents: vi.fn(), fetchBlob: vi.fn(), + getAccountId: vi.fn(() => 'source-mail-account'), getCalendarsAccountId: vi.fn(() => 'source-calendar-account'), }; @@ -26,6 +28,9 @@ const mocks = vi.hoisted(() => { const emailState = { viewingAccountId: null as string | null, + selectedMailbox: 'inbox', + mailboxes: [] as Array>, + accountMailboxes: {} as Record>>, }; const settingsState = { @@ -184,6 +189,9 @@ describe('CalendarInvitationBanner', () => { mocks.sourceClientMock.fetchBlob.mockRejectedValue(new Error('no blob')); mocks.authState.getClientForAccount.mockClear(); mocks.emailState.viewingAccountId = null; + mocks.emailState.selectedMailbox = 'inbox'; + mocks.emailState.mailboxes = []; + mocks.emailState.accountMailboxes = {}; }); it('does not parse invitations when invitation parsing is disabled', () => { @@ -228,11 +236,33 @@ describe('CalendarInvitationBanner', () => { await waitFor(() => { expect(screen.getByText('Invite in account B')).toBeInTheDocument(); }); - // Unstamped email: the owning account is the viewing client's own. - expect(mocks.sourceClientMock.parseCalendarEvents).toHaveBeenCalledWith('source-calendar-account', 'blob-1'); + // Unstamped email: the owning account is the viewing client's own mail account. + expect(mocks.sourceClientMock.parseCalendarEvents).toHaveBeenCalledWith('source-mail-account', 'blob-1'); expect(mocks.clientMock.parseCalendarEvents).not.toHaveBeenCalled(); }); + // Emails opened directly in a shared/group folder (the "Shared" sidebar + // section) are undecorated but live in the folder owner's account, reached + // through the active client - same rule as the store's resolveViewAccountId. + it('parses invitations in a directly viewed shared folder against the folder owner', async () => { + mocks.emailState.selectedMailbox = 'owner-x:x-inbox'; + mocks.emailState.mailboxes = [ + { id: 'inbox', name: 'Inbox' }, + { id: 'owner-x:x-inbox', name: 'Inbox', isShared: true, accountId: 'owner-x' }, + ]; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { uid: 'uid-x', title: 'Invite in shared folder', start: '2026-03-22T14:00:00Z', participants: {} }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Invite in shared folder')).toBeInTheDocument(); + }); + expect(mocks.clientMock.parseCalendarEvents).toHaveBeenCalledWith('owner-x', 'blob-1'); + expect(mocks.sourceClientMock.parseCalendarEvents).not.toHaveBeenCalled(); + }); + it('keeps using the active client for unstamped emails', async () => { mocks.clientMock.parseCalendarEvents.mockResolvedValue([ { uid: 'uid-a', title: 'Invite in account A', start: '2026-03-22T14:00:00Z', participants: {} }, @@ -243,7 +273,7 @@ describe('CalendarInvitationBanner', () => { await waitFor(() => { expect(screen.getByText('Invite in account A')).toBeInTheDocument(); }); - expect(mocks.clientMock.parseCalendarEvents).toHaveBeenCalledWith('calendar-account', 'blob-1'); + expect(mocks.clientMock.parseCalendarEvents).toHaveBeenCalledWith('mail-account', 'blob-1'); expect(mocks.sourceClientMock.parseCalendarEvents).not.toHaveBeenCalled(); }); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index fa280d983..af13a1d45 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -366,6 +366,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const client = useAuthStore((s) => s.client); const getClientForAccount = useAuthStore((s) => s.getClientForAccount); const viewingAccountId = useEmailStore((s) => s.viewingAccountId); + const selectedMailboxId = useEmailStore((s) => s.selectedMailbox); + const viewMailboxes = useEmailStore((s) => ( + s.viewingAccountId ? (s.accountMailboxes[s.viewingAccountId] ?? s.mailboxes) : s.mailboxes + )); // Blobs are per-account, so the invitation must be parsed and fetched through // the login the message is reachable from - its source stamp in aggregate // views, the per-account sidebar's viewing account otherwise - and against its @@ -374,7 +378,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // using the active client, whose calendars the calendar store holds. (#867) const sourceClientId = email.sourceClientAccountId ?? viewingAccountId ?? null; const parseClient = (sourceClientId ? getClientForAccount?.(sourceClientId) : undefined) ?? client; - const blobAccountId = email.sourceAccountId ?? parseClient?.getCalendarsAccountId(); + // An undecorated message opened directly in a shared/group folder (the + // "Shared" sidebar section) has no source stamp, but its blob lives in the + // folder owner's account, reached through the viewing client - mirrors + // resolveViewAccountId in the email store. Otherwise the blob is in the + // client's own mail account. + const viewedMailbox = viewMailboxes.find((m) => m.id === selectedMailboxId); + const sharedOwnerAccountId = viewedMailbox?.isShared ? viewedMailbox.accountId : undefined; + const blobAccountId = email.sourceAccountId ?? sharedOwnerAccountId ?? parseClient?.getAccountId(); const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email); const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const timeFormat = useSettingsStore((s) => s.timeFormat); diff --git a/lib/__tests__/email-date.test.ts b/lib/__tests__/email-date.test.ts new file mode 100644 index 000000000..7fc4f527e --- /dev/null +++ b/lib/__tests__/email-date.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { emailDisplayDate, MAX_FUTURE_SENT_AT_MS } from '@/lib/email-date'; + +// #891: migrated/restored mail gets receivedAt = import time; the Date header +// (sentAt) is the message's real date and must win for display. +describe('emailDisplayDate', () => { + const receivedAt = '2026-08-20T12:00:00Z'; + + it('prefers the Date header over the server receive time', () => { + expect(emailDisplayDate({ sentAt: '2019-03-04T05:06:07Z', receivedAt })).toBe('2019-03-04T05:06:07Z'); + }); + + it('falls back to receivedAt when there is no Date header', () => { + expect(emailDisplayDate({ receivedAt })).toBe(receivedAt); + expect(emailDisplayDate({ sentAt: undefined, receivedAt })).toBe(receivedAt); + expect(emailDisplayDate({ sentAt: null, receivedAt })).toBe(receivedAt); + expect(emailDisplayDate({ sentAt: '', receivedAt })).toBe(receivedAt); + }); + + it('falls back to receivedAt when the Date header is unparsable', () => { + expect(emailDisplayDate({ sentAt: 'not-a-date', receivedAt })).toBe(receivedAt); + }); + + it('tolerates ordinary clock skew (Date slightly after receipt)', () => { + const skewed = new Date(Date.parse(receivedAt) + 5 * 60 * 1000).toISOString(); + expect(emailDisplayDate({ sentAt: skewed, receivedAt })).toBe(skewed); + }); + + it('ignores a Date header implausibly far in the future (forged spam dates)', () => { + const forged = new Date(Date.parse(receivedAt) + MAX_FUTURE_SENT_AT_MS + 1000).toISOString(); + expect(emailDisplayDate({ sentAt: forged, receivedAt })).toBe(receivedAt); + }); + + it('uses sentAt when receivedAt is missing or unparsable', () => { + expect(emailDisplayDate({ sentAt: '2019-03-04T05:06:07Z' })).toBe('2019-03-04T05:06:07Z'); + expect(emailDisplayDate({ sentAt: '2019-03-04T05:06:07Z', receivedAt: 'garbage' })).toBe('2019-03-04T05:06:07Z'); + }); + + it('returns undefined when neither is present', () => { + expect(emailDisplayDate({})).toBeUndefined(); + }); +}); diff --git a/lib/email-date.ts b/lib/email-date.ts new file mode 100644 index 000000000..091dbe95b --- /dev/null +++ b/lib/email-date.ts @@ -0,0 +1,43 @@ +/** + * Which timestamp to show for a message. + * + * JMAP `receivedAt` is the server's internal date - the moment the message + * landed in the store. That is fine for freshly delivered mail, but it is not + * the mail's own date: an import, migration or backup restore that does not + * preserve internal dates stamps every message with the import time, so a whole + * mailbox suddenly "arrived" today (#891). `sentAt` is the RFC 5322 `Date` + * header, which survives such moves and is what Thunderbird, Outlook and Apple + * Mail display. + * + * Prefer `sentAt`; fall back to `receivedAt` when the header is missing, + * unparsable, or implausibly far in the future relative to the receive time + * (spam forges future dates to float to the top of date-sorted lists; genuine + * clock skew is minutes, not days). + * + * List *ordering* is unaffected - queries still sort by `receivedAt`, which is + * monotonic and cannot be forged by the sender. + */ + +/** How far ahead of `receivedAt` a `Date` header may be before it is ignored. */ +export const MAX_FUTURE_SENT_AT_MS = 24 * 60 * 60 * 1000; + +interface DatedEmail { + sentAt?: string | null; + receivedAt?: string | null; +} + +/** `string` when the input is guaranteed a `receivedAt` (a full `Email`), else possibly undefined. */ +type DisplayDate = T extends { receivedAt: string } ? string : string | undefined; + +export function emailDisplayDate(email: T): DisplayDate { + const { sentAt, receivedAt } = email; + const fallback = (receivedAt ?? undefined) as DisplayDate; + if (!sentAt) return fallback; + const sent = Date.parse(sentAt); + if (Number.isNaN(sent)) return fallback; + if (receivedAt) { + const received = Date.parse(receivedAt); + if (!Number.isNaN(received) && sent - received > MAX_FUTURE_SENT_AT_MS) return fallback; + } + return sentAt as DisplayDate; +}