mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
fix: preserve mailbox owner in push notification links
This commit is contained in:
@@ -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, '', '/');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user