Files
webmail/lib/__tests__/deep-links.test.ts
T
Linus Rath 2bb0797996 feat: archive, mark read or reply right from a new mail notification
A notification for one message gets buttons where the browser has room
for them, and a click on its body works as before. The service worker
has no login, so an open tab takes the action first, through the store
actions. With no tab open a new same-origin route, /api/push/action,
does Mark read and Archive with the context cookie. When the cookie is
gone, the token has expired or the login can't be told apart, the
message opens in the app instead. Reply opens the composer through a
`?reply=1` link, with any text typed on the notification.

- `public/sw.js`: buttons, asking open tabs, the route, `sync` handling
- `app/api/push/action`: same-origin, checks the login and the account,
  honours the admin's Archive gate, files by the mirrored archive mode
- `lib/pwa-notify`: the page's side, the mirror for the worker, and
  `requestOutboxSync`, which the offline queue calls
- Background Sync, thin version: a `sync` event tells open tabs to flush

Two push-preview-route assertions change: they match the whole answer
for a named message, which now also carries `login`, what the action
route checks so a stale notification can't act on whoever holds the
cookie slot now.
2026-11-03 05:26:43 +01:00

419 lines
17 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import {
buildFolderRef,
resolveFolderRef,
isFolderLinkOpen,
pathNamesMailFolder,
buildMailPath,
parseMailPath,
buildCalendarPath,
parseCalendarPath,
formatLinkDate,
parseLinkDate,
buildContactsPath,
parseContactsPath,
buildFilesPath,
buildSharedFilesPath,
parseFilesPath,
buildSettingsPath,
parseSettingsPath,
parseSettingsSection,
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('isFolderLinkOpen', () => {
const plainInbox = {
selectedMailbox: 'a',
isUnifiedView: false,
isScheduledView: false,
selectedKeyword: null,
hasSearch: false,
};
it('treats the folder the list already shows as open (page reload)', () => {
expect(isFolderLinkOpen(resolveFolderRef('inbox', MAILBOXES)!, plainInbox)).toBe(true);
});
it('does not treat another folder as open', () => {
expect(isFolderLinkOpen('b', plainInbox)).toBe(false);
});
it('does not treat the folder as open while a tag, search or special view covers it', () => {
expect(isFolderLinkOpen('a', { ...plainInbox, selectedKeyword: 'work' })).toBe(false);
expect(isFolderLinkOpen('a', { ...plainInbox, hasSearch: true })).toBe(false);
expect(isFolderLinkOpen('a', { ...plainInbox, isUnifiedView: true })).toBe(false);
expect(isFolderLinkOpen('a', { ...plainInbox, isScheduledView: true })).toBe(false);
});
it('never treats a virtual view as open - the boot fetch does not load them', () => {
for (const id of [UNIFIED_MAILBOX_IDS.inbox, CROSS_VIEW_IDS.unread, SCHEDULED_MAILBOX_ID]) {
expect(isFolderLinkOpen(id, { ...plainInbox, selectedMailbox: id })).toBe(false);
}
});
});
describe('pathNamesMailFolder', () => {
it('spots a folder link behind any mount and locale prefix', () => {
expect(pathNamesMailFolder('/mail/folder/trash')).toBe(true);
expect(pathNamesMailFolder('/en/mail/folder/inbox')).toBe(true);
expect(pathNamesMailFolder('/webmail/de/mail/folder/Mxyz/')).toBe(true);
});
it('is false for the bare app, other mail links and other surfaces', () => {
for (const path of ['/', '/en', '/en/mail', '/mail/folder/', '/en/mail/message/m1', '/en/calendar', '/email/folder/x']) {
expect(pathNamesMailFolder(path), path).toBe(false);
}
});
});
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 a notification's reply flag on a message link, and only there", () => {
expect(parseMailPath(['message', 'm1'], new URLSearchParams('slot=1&owner=a&reply=1'))).toMatchObject({
kind: 'message', id: 'm1', slot: 1, ownerAccountId: 'a', reply: true,
});
expect(parseMailPath(['message', 'm1'], new URLSearchParams('reply=0'))).not.toHaveProperty('reply');
expect(parseMailPath(['message', 'm1'])).not.toHaveProperty('reply');
expect(parseMailPath(['folder', 'inbox'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply');
expect(parseMailPath(['thread', 't1'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply');
});
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' });
});
// Email ids repeat across accounts: a link to a shared account's message
// names that account, or it opens the own message with the same id (#1351).
it('names the account owning a message or conversation outside the own one', () => {
const message = buildMailPath({ mailboxId: null, emailId: 'm1', threadId: null, ownerAccountId: 'cn', loginId: 'bob@x', fullscreen: true });
expect(message).toBe('/mail/message/m1?account=bob%40x&owner=cn&view=fullscreen');
const [, query] = message.split('?');
expect(parseMailPath(['message', 'm1'], new URLSearchParams(query))).toEqual({
kind: 'message', id: 'm1', accountId: 'bob@x', ownerAccountId: 'cn', fullscreen: true,
});
expect(buildMailPath({ mailboxId: null, emailId: null, threadId: 't1', ownerAccountId: 'cn' })).toBe('/mail/thread/t1?owner=cn');
expect(parseMailPath(['thread', 't1'], new URLSearchParams('owner=cn'))).toEqual({
kind: 'thread', id: 't1', accountId: undefined, ownerAccountId: 'cn',
});
// Only a message link opens fullscreen; a folder link names no owner.
expect(buildMailPath({ mailboxId: null, emailId: null, threadId: 't1', fullscreen: true })).toBe('/mail/thread/t1');
expect(buildMailPath({ mailboxId: 'c', emailId: null, threadId: null, ownerAccountId: 'cn' }, MAILBOXES)).toBe('/mail/folder/c');
});
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' });
});
it('names a node of a shared drive by id, since it has no path under the user\'s root', () => {
const link = buildSharedFilesPath('owner1:n5', 'Q3 plan.pdf');
expect(link).toBe('/files?shared=owner1%3An5&preview=Q3%20plan.pdf');
const [, query] = link.split('?');
expect(parseFilesPath([], new URLSearchParams(query)))
.toEqual({ path: '/', preview: 'Q3 plan.pdf', shared: 'owner1:n5' });
// An owner's page of files shared on their own.
expect(parseFilesPath([], new URLSearchParams(buildSharedFilesPath('owner1:*').split('?')[1])))
.toEqual({ path: '/', preview: undefined, shared: 'owner1:*' });
});
});
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();
});
it('round-trips a section of a tab', () => {
expect(buildSettingsPath('organize', 'tags')).toBe('/settings/organize/tags');
expect(buildSettingsPath('organize', null)).toBe('/settings/organize');
expect(parseSettingsPath(['organize', 'tags'])).toBe('organize');
expect(parseSettingsSection(['organize', 'tags'])).toBe('tags');
expect(parseSettingsSection(['organize'])).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();
});
});