Files
webmail/lib/__tests__/deep-links.test.ts
T
Linus Rath 4858eb0915 fix: open a push notification's message in the account it came from
Clicking a new-mail notification opened `/mail/message/<id>` in
whichever account was active. With several accounts signed in, that
looked the id up in the wrong account: nothing opened, or a different
message did and was marked read.

The preview API now returns the cookie slot of the login that owns the
notified account, the service worker adds it to the permalink (or links
that account's Inbox for a group notification), and the app switches to
the login on that slot before opening the message.
2026-09-26 16:12:20 +02:00

323 lines
12 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import {
buildFolderRef,
resolveFolderRef,
buildMailPath,
parseMailPath,
buildCalendarPath,
parseCalendarPath,
formatLinkDate,
parseLinkDate,
buildContactsPath,
parseContactsPath,
buildFilesPath,
parseFilesPath,
buildSettingsPath,
parseSettingsPath,
matchSurface,
SCHEDULED_MAILBOX_ID,
} from '@/lib/deep-links';
import {
UNIFIED_MAILBOX_IDS,
CROSS_VIEW_IDS,
type Mailbox,
} from '@/lib/jmap/types';
function mailbox(partial: Partial<Mailbox> & { id: string }): Mailbox {
return {
name: partial.id,
totalEmails: 0,
unreadEmails: 0,
...partial,
} as Mailbox;
}
const MAILBOXES: Mailbox[] = [
mailbox({ id: 'a', role: 'inbox', name: 'Inbox' }),
mailbox({ id: 'b', role: 'sent', name: 'Sent' }),
mailbox({ id: 'c', name: 'Projects/2026' }),
mailbox({ id: 'shared-a', role: 'inbox', name: 'Team Inbox', isShared: true, accountId: 'team' }),
];
// `appPath` bakes NEXT_PUBLIC_LOCALE_PREFIX / NEXT_PUBLIC_BASE_PATH at module
// load (mirroring how next.config.ts and i18n/routing.ts read them at build
// time), so prefix-mode cases have to re-import under a fresh env.
async function loadLinks(env: { localePrefix?: string; basePath?: string }) {
vi.resetModules();
if (env.localePrefix === undefined) delete process.env.NEXT_PUBLIC_LOCALE_PREFIX;
else process.env.NEXT_PUBLIC_LOCALE_PREFIX = env.localePrefix;
if (env.basePath === undefined) delete process.env.NEXT_PUBLIC_BASE_PATH;
else process.env.NEXT_PUBLIC_BASE_PATH = env.basePath;
return import('@/lib/deep-links');
}
afterEach(() => {
delete process.env.NEXT_PUBLIC_LOCALE_PREFIX;
delete process.env.NEXT_PUBLIC_BASE_PATH;
vi.resetModules();
});
describe('appPath — locale prefix and mount prefix', () => {
it('omits the locale in the default "never" mode', async () => {
const { appPath } = await loadLinks({});
expect(appPath('/mail/message/abc', 'de')).toBe('/mail/message/abc');
});
it('always prefixes the locale in "always" mode', async () => {
const { appPath } = await loadLinks({ localePrefix: 'always' });
expect(appPath('/mail/message/abc', 'de')).toBe('/de/mail/message/abc');
expect(appPath('/', 'de')).toBe('/de');
});
it('prefixes only non-default locales in "as-needed" mode', async () => {
const { appPath } = await loadLinks({ localePrefix: 'as-needed' });
expect(appPath('/calendar', 'en')).toBe('/calendar');
expect(appPath('/calendar', 'fr')).toBe('/fr/calendar');
});
it('applies the mount prefix on subpath deployments', async () => {
const { appPath } = await loadLinks({ localePrefix: 'always', basePath: '/webmail' });
expect(appPath('/mail/thread/t1', 'de')).toBe('/webmail/de/mail/thread/t1');
});
it('builds an absolute URL for copy-link', async () => {
const { appUrl } = await loadLinks({});
expect(appUrl('/mail/message/abc')).toBe(`${window.location.origin}/mail/message/abc`);
});
});
describe('mail folder references', () => {
it('uses the role name for the account\'s own well-known folders', () => {
expect(buildFolderRef('a', MAILBOXES)).toBe('inbox');
expect(buildFolderRef('b', MAILBOXES)).toBe('sent');
});
it('keeps the raw id for custom folders', () => {
expect(buildFolderRef('c', MAILBOXES)).toBe('c');
});
it('keeps the raw id for shared folders so they don\'t claim the role alias', () => {
expect(buildFolderRef('shared-a', MAILBOXES)).toBe('shared-a');
expect(resolveFolderRef('inbox', MAILBOXES)).toBe('a');
});
it('aliases the virtual unified, cross-account and scheduled views', () => {
expect(buildFolderRef(UNIFIED_MAILBOX_IDS.inbox)).toBe('unified-inbox');
expect(buildFolderRef(CROSS_VIEW_IDS.unread)).toBe('cross-unread');
expect(buildFolderRef(SCHEDULED_MAILBOX_ID)).toBe('scheduled');
expect(resolveFolderRef('unified-inbox')).toBe(UNIFIED_MAILBOX_IDS.inbox);
expect(resolveFolderRef('cross-unread')).toBe(CROSS_VIEW_IDS.unread);
expect(resolveFolderRef('scheduled')).toBe(SCHEDULED_MAILBOX_ID);
});
it('prefers an exact id match over an alias', () => {
const odd = [...MAILBOXES, mailbox({ id: 'inbox', name: 'A folder literally called inbox' })];
expect(resolveFolderRef('inbox', odd)).toBe('inbox');
});
it('returns null for a role that this account does not have', () => {
expect(resolveFolderRef('archive', MAILBOXES)).toBeNull();
});
});
describe('mail paths', () => {
it('round-trips folder, message and thread links', () => {
const folder = buildMailPath({ mailboxId: 'c', emailId: null, threadId: null }, MAILBOXES);
expect(folder).toBe('/mail/folder/c');
expect(parseMailPath(['folder', 'c'])).toEqual({ kind: 'folder', ref: 'c', accountId: undefined });
const message = buildMailPath({ mailboxId: 'a', emailId: 'm1', threadId: null }, MAILBOXES);
expect(message).toBe('/mail/message/m1');
expect(parseMailPath(['message', 'm1'])).toEqual({ kind: 'message', id: 'm1', accountId: undefined });
const thread = buildMailPath({ mailboxId: 'a', emailId: 'm1', threadId: 't1' }, MAILBOXES);
expect(thread).toBe('/mail/thread/t1');
expect(parseMailPath(['thread', 't1'])).toEqual({ kind: 'thread', id: 't1', accountId: undefined });
});
it('reads the fullscreen marker on message links', () => {
expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=fullscreen'))).toEqual({
kind: 'message',
id: 'm1',
accountId: undefined,
fullscreen: true,
});
expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=other'))).toEqual({
kind: 'message',
id: 'm1',
accountId: undefined,
});
});
it('reads the login slot a push notification names', () => {
expect(parseMailPath(['message', 'm1'], new URLSearchParams('slot=2'))).toEqual({
kind: 'message', id: 'm1', accountId: undefined, slot: 2,
});
expect(parseMailPath(['folder', 'inbox'], new URLSearchParams('slot=0'))).toEqual({
kind: 'folder', ref: 'inbox', accountId: undefined, slot: 0,
});
for (const bad of ['slot=', 'slot=-1', 'slot=1.5', 'slot=x']) {
expect(parseMailPath(['message', 'm1'], new URLSearchParams(bad))).toEqual({
kind: 'message', id: 'm1', accountId: undefined,
});
}
});
it('falls back to the bare mail path with nothing selected', () => {
expect(buildMailPath({ mailboxId: null, emailId: null, threadId: null })).toBe('/mail');
expect(parseMailPath([])).toBeNull();
});
it('percent-encodes ids that are not path-safe', () => {
const path = buildMailPath({ mailboxId: null, emailId: 'a/b c?d', threadId: null });
expect(path).toBe('/mail/message/a%2Fb%20c%3Fd');
const segment = path.split('/').pop()!;
expect(parseMailPath(['message', segment])).toEqual({
kind: 'message',
id: 'a/b c?d',
accountId: undefined,
});
});
it('survives a malformed percent-sequence instead of throwing', () => {
expect(parseMailPath(['message', '%E0%A4%A'])).toEqual({
kind: 'message',
id: '%E0%A4%A',
accountId: undefined,
});
});
it('carries the account disambiguator', () => {
const link = parseMailPath(['message', 'm1'], new URLSearchParams('account=acc-2'));
expect(link).toEqual({ kind: 'message', id: 'm1', accountId: 'acc-2' });
});
it('still understands the legacy ?email= notification link', () => {
expect(parseMailPath([], new URLSearchParams('email=m9'))).toEqual({
kind: 'message',
id: 'm9',
accountId: undefined,
});
});
it('ignores unknown segments rather than inventing an intent', () => {
expect(parseMailPath(['nonsense', 'x'])).toBeNull();
expect(parseMailPath(['message'])).toBeNull();
});
});
describe('calendar paths', () => {
it('formats and parses dates in local time', () => {
const date = new Date(2026, 7, 6);
expect(formatLinkDate(date)).toBe('2026-08-06');
expect(parseLinkDate('2026-08-06')?.getTime()).toBe(date.getTime());
});
it('rejects malformed and impossible dates', () => {
expect(parseLinkDate('2026-8-6')).toBeNull();
expect(parseLinkDate('2026-02-31')).toBeNull();
expect(parseLinkDate('nope')).toBeNull();
});
it('round-trips a view and date', () => {
const path = buildCalendarPath({ view: 'day', date: new Date(2026, 7, 6) });
expect(path).toBe('/calendar/day/2026-08-06');
const link = parseCalendarPath(['day', '2026-08-06']);
expect(link?.kind).toBe('view');
expect(link).toMatchObject({ view: 'day' });
});
it('round-trips an event, with the account that owns it', () => {
expect(buildCalendarPath({ view: 'month', date: null, eventId: 'e1' })).toBe('/calendar/event/e1');
expect(buildCalendarPath({ view: 'month', date: null, eventId: 'e1', accountId: 'acc-2' }))
.toBe('/calendar/event/e1?account=acc-2');
expect(parseCalendarPath(['event', 'e1'], new URLSearchParams('account=acc-2')))
.toEqual({ kind: 'event', id: 'e1', accountId: 'acc-2' });
});
it('accepts a bare date with no view', () => {
expect(parseCalendarPath(['2026-08-06'])).toMatchObject({ kind: 'view', view: 'month' });
});
it('returns null for an unknown view', () => {
expect(parseCalendarPath(['quarter'])).toBeNull();
expect(parseCalendarPath([])).toBeNull();
});
});
describe('contacts paths', () => {
it('round-trips detail and edit links', () => {
expect(buildContactsPath({ contactId: 'c1' })).toBe('/contacts/c1');
expect(buildContactsPath({ contactId: 'c1', editing: true })).toBe('/contacts/c1/edit');
expect(parseContactsPath(['c1'])).toEqual({ kind: 'contact', id: 'c1', edit: false, fromEmail: false });
expect(parseContactsPath(['c1', 'edit'])).toEqual({ kind: 'contact', id: 'c1', edit: true, fromEmail: false });
});
it('keeps the legacy query form working', () => {
expect(parseContactsPath([], new URLSearchParams('contactId=c1&view=edit&from=email')))
.toEqual({ kind: 'contact', id: 'c1', edit: true, fromEmail: true });
expect(parseContactsPath([], new URLSearchParams('addEmail=a%40b.c&addName=Ada')))
.toEqual({ kind: 'create', email: 'a@b.c', name: 'Ada', fromEmail: false });
});
it('handles the create link', () => {
expect(parseContactsPath(['new'], new URLSearchParams('email=a%40b.c')))
.toEqual({ kind: 'create', email: 'a@b.c', name: undefined, fromEmail: false });
});
});
describe('files paths', () => {
it('round-trips a nested folder', () => {
expect(buildFilesPath('/Documents/Invoices 2026')).toBe('/files/Documents/Invoices%202026');
expect(parseFilesPath(['Documents', 'Invoices%202026'])).toEqual({
path: '/Documents/Invoices 2026',
preview: undefined,
});
});
it('maps the drive root to the bare path', () => {
expect(buildFilesPath('/')).toBe('/files');
expect(parseFilesPath([])).toBeNull();
});
it('carries a file to preview', () => {
expect(buildFilesPath('/Documents', 'report.pdf')).toBe('/files/Documents?preview=report.pdf');
expect(parseFilesPath(['Documents'], new URLSearchParams('preview=report.pdf')))
.toEqual({ path: '/Documents', preview: 'report.pdf' });
});
});
describe('settings paths', () => {
it('round-trips a tab', () => {
expect(buildSettingsPath('appearance')).toBe('/settings/appearance');
expect(buildSettingsPath(null)).toBe('/settings');
expect(parseSettingsPath(['appearance'])).toBe('appearance');
expect(parseSettingsPath([])).toBeNull();
});
});
describe('matchSurface', () => {
it('maps the app root and /mail to the mail surface', () => {
expect(matchSurface('/')).toEqual({ surface: 'mail', segments: [] });
expect(matchSurface('/mail')).toEqual({ surface: 'mail', segments: [] });
expect(matchSurface('/mail/message/m1')).toEqual({ surface: 'mail', segments: ['message', 'm1'] });
});
it('ignores a leading locale segment', () => {
expect(matchSurface('/de/calendar/day/2026-08-06'))
.toEqual({ surface: 'calendar', segments: ['day', '2026-08-06'] });
expect(matchSurface('/de')).toEqual({ surface: 'mail', segments: [] });
});
it('strips query and hash', () => {
expect(matchSurface('/contacts/c1?from=email#x'))
.toEqual({ surface: 'contacts', segments: ['c1'] });
});
it('returns null for routes that are not app surfaces', () => {
expect(matchSurface('/pro')).toBeNull();
expect(matchSurface('/login')).toBeNull();
});
});