mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: parse calendar invitations in directly viewed shared folders against the folder owner #867
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user