fix: parse calendar invitations in directly viewed shared folders against the folder owner #867

This commit is contained in:
Linus Rath
2026-08-25 19:36:04 +02:00
parent 1faa6f6900
commit 3472bc0419
4 changed files with 130 additions and 4 deletions
@@ -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<Record<string, unknown>>,
accountMailboxes: {} as Record<string, Array<Record<string, unknown>>>,
};
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(<CalendarInvitationBanner email={makeEmail({ mailboxIds: { 'owner-x:x-inbox': true } })} />);
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();
});
@@ -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);
+42
View File
@@ -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();
});
});
+43
View File
@@ -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 DatedEmail> = T extends { receivedAt: string } ? string : string | undefined;
export function emailDisplayDate<T extends DatedEmail>(email: T): DisplayDate<T> {
const { sentAt, receivedAt } = email;
const fallback = (receivedAt ?? undefined) as DisplayDate<T>;
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<T>;
}