fix: preserve mailbox owner in push notification links

This commit is contained in:
Szymon Chroboczek
2026-11-02 12:47:44 +01:00
parent 3f3dfebb84
commit 62906e45dc
7 changed files with 241 additions and 12 deletions
@@ -256,3 +256,29 @@ describe('archiving a group message by month', () => {
expect(client.createMailbox).toHaveBeenCalledWith('2026', 'k', 'ce', undefined);
});
});
describe('shared message deep link from the personal Inbox (#1491)', () => {
it('selects the shared folder for a freshly fetched message without source stamps', async () => {
window.history.replaceState(null, '', '/mail/message/eaaaaab?owner=ce');
const email = { ...groupMessage, mailboxIds: { 'ce:a': true }, sourceClientAccountId: undefined, sourceAccountId: undefined } as Email;
const fetchContent = vi.fn().mockImplementation(async () => {
useEmailStore.setState({ selectedEmail: email });
return email;
});
const fetchEmails = vi.fn().mockResolvedValue(undefined);
useEmailStore.setState({
emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null,
fetchEmailContent: fetchContent, fetchEmails,
});
const mounted = render(<MailApp linkSegments={['message', 'eaaaaab']} />);
try {
await waitFor(() => expect(fetchContent).toHaveBeenCalledWith(client, 'eaaaaab', 'ce'));
await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
expect(fetchEmails).toHaveBeenCalledWith(client, 'ce:a', { background: true });
expect(useEmailStore.getState().selectedEmail?.subject).toBe('GRP one');
} finally {
mounted.unmount();
window.history.replaceState(null, '', '/');
}
});
});
+5 -1
View File
@@ -1796,7 +1796,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? await freshMailboxes(switchedFrom)
: useEmailStore.getState().mailboxes;
const state = useEmailStore.getState();
const mailboxId = resolveFolderRef(link.ref, mailboxes);
const mailboxId = resolveFolderRef(
link.ref,
mailboxes,
link.ownerAccountId === activeClient.getAccountId() ? undefined : link.ownerAccountId,
);
if (!mailboxId) {
toast.error(t('deep_link.folder_not_found'));
return;
+75
View File
@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS, GROUP, WEBMAIL_URL } from './helpers/config';
import { JmapClient } from './helpers/jmap';
import { login, seedSettings, folderRow, expandSharedFolders, emailItem, addAccount, switchAccount } from './helpers/app';
import { pushClick } from '../../lib/__tests__/helpers/push-click';
test.describe('notification target across personal and shared accounts (#1491)', () => {
const cases = [false, true].flatMap(shared => [1, 3].flatMap(unread =>
[false, true].map(warm => ({ shared, unread, warm, secondary: false })),
));
cases.push(...[1, 3].map(unread => ({ shared: true, unread, warm: true, secondary: true })));
for (const { shared, unread, warm, secondary } of cases) {
test(`${shared ? 'shared' : 'personal'}, ${unread} unread, ${warm ? 'existing' : 'new'} window${secondary ? ', second login' : ''}`, async ({ page }) => {
const member = ACCOUNTS.carol;
const client = await JmapClient.connect(member.email, member.password);
const sharedId = client.accountIdByName(GROUP.team.email);
await client.reset();
await client.reset(sharedId);
const targetId = shared ? sharedId : client.accountId;
let deliveredId = '';
const subject = `Push target ${shared ? 'shared' : 'personal'} ${unread}`;
for (const owner of [client.accountId, sharedId]) {
const inbox = (await client.mailboxByRole('inbox', owner))!;
for (let i = 0; i < unread; i++) {
const result = await client.request([['Email/set', {
accountId: owner,
create: { mail: {
mailboxIds: { [inbox.id]: true }, keywords: {},
from: [{ email: ACCOUNTS.alice.email }],
to: [{ email: owner === sharedId ? GROUP.team.email : member.email }],
subject: owner === targetId ? `${subject} ${i}` : `Other mailbox ${i}`,
receivedAt: new Date(Date.now() + i * 1000).toISOString(),
textBody: [{ partId: 'b', type: 'text/plain' }],
bodyValues: { b: { value: `Body in ${owner}` } },
} },
}, '0']]);
expect(result.methodResponses[0][1].created?.mail).toBeTruthy();
if (owner === targetId) deliveredId = result.methodResponses[0][1].created.mail.id;
}
}
await seedSettings(page, { enableUnifiedMailbox: false, mailLayout: 'split' });
if (secondary) {
await login(page, ACCOUNTS.bob);
await addAccount(page, member);
await switchAccount(page, ACCOUNTS.bob.email);
} else {
await login(page, member);
}
await expect(folderRow(page, { role: 'inbox', shared: false }).first()).toHaveAttribute('data-selected', 'true');
const target = await pushClick(targetId, async (url) => {
const response = await page.request.get(new URL(url, WEBMAIL_URL).href);
expect(response.status()).toBe(200);
const preview = await response.json();
expect(preview.unreadTotal).toBe(unread);
expect(preview.slot).toBe(secondary ? 1 : 0);
expect(preview.email.subject).toBe(`${subject} ${unread - 1}`);
return preview;
}, { warm, emailId: deliveredId });
if (!warm) {
const nextPage = await page.context().newPage();
await page.close();
page = nextPage;
}
await page.goto(target);
if (unread === 1) {
await expect(page.locator('[data-tour="email-viewer"]')).toContainText(`${subject} 0`);
}
if (shared) await expandSharedFolders(page, GROUP.team.email);
await expect(folderRow(page, { role: 'inbox', shared }).first()).toHaveAttribute('data-selected', 'true');
if (unread > 1) {
await expect(emailItem(page, `${subject} ${unread - 1}`).first()).toBeVisible();
}
});
}
});
+57
View File
@@ -0,0 +1,57 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
export async function pushClick(
accountId: string,
preview: (url: string) => Promise<unknown>,
options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string } = {},
): Promise<string> {
const source = readFileSync(resolve('public/sw.js'), 'utf8');
let notification: { data: unknown } | undefined;
let target: string | undefined;
const listeners = new Map<string, (event: unknown) => void>();
const cache = new Map<string, string>();
const client = {
id: 'window-1', url: 'https://mail.example.test/',
postMessage() {},
async focus() { return this; },
async navigate(url: string) { target = url; return this; },
};
const context = vm.createContext({
URL, URLSearchParams, Response, AbortController, setTimeout, clearTimeout, console,
self: {
location: { href: `https://mail.example.test${options.basePath ?? ''}/sw.js`, origin: 'https://mail.example.test' },
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
clients: {
matchAll: async () => options.warm ? [client] : [],
openWindow: async (url: string) => { target = url; },
},
registration: {
showNotification: async (_title: string, data: { data: unknown }) => { notification = data; },
getNotifications: async () => [],
},
},
caches: { open: async () => ({
match: async (key: string) => cache.has(key) ? new Response(cache.get(key)) : undefined,
put: async (key: string, response: Response) => cache.set(key, await response.text()),
}) },
fetch: async (url: string) => ({ ok: true, json: async () => preview(url) }),
});
vm.runInContext(source, context);
async function dispatch(type: string, event: Record<string, unknown>) {
const waits: Promise<unknown>[] = [];
const listener = listeners.get(type);
if (!listener) throw new Error(`Missing worker handler: ${type}`);
listener({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
await Promise.all(waits);
}
const payload = options.legacy
? { changed: { [accountId]: { EmailDelivery: 'new' } } }
: { kind: 'jmap-email-push', accountId, emailIds: [options.emailId ?? 'new-email'] };
await dispatch('push', { data: { json: () => payload } });
if (!notification) throw new Error('No notification shown');
await dispatch('notificationclick', { notification: { ...notification, close() {} } });
if (!target) throw new Error('Notification did not navigate');
return target;
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { parseMailPath, resolveFolderRef } from '@/lib/deep-links';
import type { Mailbox } from '@/lib/jmap/types';
import { pushClick } from './helpers/push-click';
const folders = [
{ id: 'a', name: 'Inbox', role: 'inbox', accountId: 'personal', totalEmails: 0, unreadEmails: 0 },
{ id: 'team:a', originalId: 'a', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'team', totalEmails: 0, unreadEmails: 0 },
] as Mailbox[];
function parseTarget(target: string) {
const url = new URL(target, 'https://mail.example.test');
return parseMailPath(url.pathname.split('/mail/')[1].split('/'), url.searchParams);
}
describe('push click keeps the owning mailbox (#1491)', () => {
for (const owner of ['personal', 'team']) {
for (const unreadTotal of [1, 3]) {
for (const warm of [false, true]) {
it(`${owner}, ${unreadTotal} unread, ${warm ? 'existing' : 'new'} window`, async () => {
const target = await pushClick(owner, async (url) => {
expect(new URL(url, 'https://mail.example.test').searchParams.get('accountId')).toBe(owner);
return { slot: 0, unreadTotal, email: { id: 'same-id', threadId: 't', subject: 'Mail' } };
}, { warm });
const link = parseTarget(target);
expect(link?.slot).toBe(0);
if (unreadTotal === 1) {
expect(link).toMatchObject({ kind: 'message', id: 'same-id', ownerAccountId: owner });
} else {
expect(link).toMatchObject({ kind: 'folder', ref: 'inbox', ownerAccountId: owner });
if (link?.kind !== 'folder') throw new Error('Expected folder link');
expect(resolveFolderRef(link.ref, folders, link.ownerAccountId)).toBe(owner === 'team' ? 'team:a' : 'a');
}
});
}
}
}
it('keeps the owner when organisation policy hides message details', async () => {
const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 1, redacted: true, email: { id: 'same-id' } }));
expect(parseTarget(target)).toMatchObject({ kind: 'message', ownerAccountId: 'team', slot: 2 });
});
it('keeps the login slot and owner with a mount prefix and legacy payload', async () => {
const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true });
expect(target).toMatch(/^\/webmail\/mail\/folder\/inbox\?/);
expect(parseTarget(target)).toMatchObject({ kind: 'folder', ownerAccountId: 'team', slot: 2 });
});
it('does not fall back to a personal folder for an unavailable owner', () => {
expect(resolveFolderRef('inbox', folders, 'unavailable')).toBeNull();
});
it('keeps old unscoped folder links working', () => {
expect(resolveFolderRef('inbox', folders)).toBe('a');
});
});
+12 -3
View File
@@ -136,7 +136,7 @@ const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'j
* login by its cookie slot instead - what a push notification knows.
*/
export type MailDeepLink =
| { kind: 'folder'; ref: string; accountId?: string; slot?: number }
| { kind: 'folder'; ref: string; accountId?: string; slot?: number; ownerAccountId?: string }
| { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string }
| { kind: 'thread'; id: string; accountId?: string; slot?: number; ownerAccountId?: string };
@@ -179,9 +179,18 @@ export function buildFolderRef(mailboxId: string, mailboxes: Mailbox[] = []): st
* Exact ids are checked before aliases so a custom folder whose id happens to
* read like an alias still resolves to itself.
*/
export function resolveFolderRef(ref: string, mailboxes: Mailbox[] = []): string | null {
export function resolveFolderRef(ref: string, mailboxes: Mailbox[] = [], ownerAccountId?: string): string | null {
const decoded = decodeSegment(ref);
if (ownerAccountId) {
const owned = mailboxes.filter((mailbox) => mailbox.accountId === ownerAccountId);
const exact = owned.find((mailbox) => mailbox.id === decoded);
if (exact) return exact.id;
return ALIASED_ROLES.has(decoded)
? owned.find((mailbox) => mailbox.role === decoded)?.id ?? null
: null;
}
if (mailboxes.some((m) => m.id === decoded)) return decoded;
const virtual = VIRTUAL_FOLDER_ALIASES[decoded];
@@ -284,7 +293,7 @@ export function parseMailPath(
if (id) {
if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId };
if (kind === 'thread') return { kind: 'thread', id, accountId, slot, ownerAccountId };
if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot };
if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot, ownerAccountId };
}
}
+9 -8
View File
@@ -521,7 +521,7 @@ async function handlePush(event) {
// (Organisation > Devices): the route sent neither.
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, slot };
if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot };
} else if (email) {
const sender = email.from && email.from[0];
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
@@ -534,7 +534,7 @@ async function handlePush(event) {
body += "\n" + (more === 1 ? "+1 more message" : `+${more} more messages`);
} else {
// Exactly one unread: deep-link straight to that message on click.
data = { kind: "email", emailId: email.id, threadId: email.threadId, slot };
data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot };
}
} else {
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
@@ -922,16 +922,17 @@ function getReusableClientScore(state) {
function buildClickUrl(data) {
if (!data) return `${BASE_PATH}/`;
// `?slot=` names the login the message belongs to (see the preview API).
const slotQuery = Number.isInteger(data.slot) ? `?slot=${data.slot}` : "";
const params = new URLSearchParams();
if (Number.isInteger(data.slot)) params.set("slot", String(data.slot));
if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId);
const targetQuery = params.size ? `?${params}` : "";
if (data.kind === "email" && data.emailId) {
// Permalink (#733). Under NEXT_PUBLIC_LOCALE_PREFIX=always the proxy
// redirects this to the localised path; the worker has no locale to add.
return `${BASE_PATH}/mail/message/${encodeURIComponent(data.emailId)}${slotQuery}`;
return `${BASE_PATH}/mail/message/${encodeURIComponent(data.emailId)}${targetQuery}`;
}
// A group of new mail in a known login: that login's Inbox.
if (data.kind === "mail-list" && slotQuery) {
return `${BASE_PATH}/mail/folder/inbox${slotQuery}`;
if (data.kind === "mail-list" && Number.isInteger(data.slot)) {
return `${BASE_PATH}/mail/folder/inbox${targetQuery}`;
}
// Generic "New mail" toast (preview API failed or returned no email): land
// the user on the latest unread message in their Inbox rather than just the