mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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.
258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { endlessResponse } from './helpers/endless-body';
|
|
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
|
|
|
|
vi.mock('next/server', () => ({
|
|
NextResponse: {
|
|
json: (data: unknown, init?: { status?: number }) => ({
|
|
json: async () => data,
|
|
status: init?.status ?? 200,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('next/headers', () => ({
|
|
cookies: async () => ({ get: () => undefined }),
|
|
}));
|
|
|
|
vi.mock('@/lib/logger', () => ({
|
|
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 2 }));
|
|
|
|
vi.mock('@/lib/stalwart/auth-context', () => ({
|
|
readStalwartAuthContextFromStore: (_store: unknown, slot: number) =>
|
|
slot === 0 ? { serverUrl: 'https://mail.example.com/', authHeader: 'Bearer tok' } : null,
|
|
}));
|
|
|
|
vi.mock('@/lib/stalwart/credentials', () => ({
|
|
getStalwartCredentials: vi.fn(),
|
|
}));
|
|
|
|
const fetchJmapServer = vi.fn();
|
|
vi.mock('@/lib/stalwart/server-fetch', () => ({
|
|
fetchJmapServer: (...args: unknown[]) => fetchJmapServer(...args),
|
|
isTrustedJmapServerUrl: async () => true,
|
|
}));
|
|
|
|
vi.mock('@/lib/security/url-guard', () => ({
|
|
DisallowedUrlError: class DisallowedUrlError extends Error {},
|
|
}));
|
|
|
|
// The route reads the device policy (Organisation > Devices); none is set here.
|
|
vi.mock('@/lib/admin/config-manager', async () => {
|
|
const { DEFAULT_POLICY } = await import('@/lib/admin/types');
|
|
return { configManager: { ensureLoaded: async () => {}, getPolicy: () => DEFAULT_POLICY } };
|
|
});
|
|
|
|
function jsonResponse(data: unknown) {
|
|
return { ok: true, json: async () => data };
|
|
}
|
|
|
|
const SESSION = {
|
|
apiUrl: 'https://mail.example.com/jmap',
|
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' },
|
|
accounts: { a: { name: 'me@example.com' }, g: { name: 'group@example.com' } },
|
|
};
|
|
|
|
function mockJmap() {
|
|
fetchJmapServer.mockImplementation(async (url: string, init?: { body?: string }) => {
|
|
if (url.endsWith('/.well-known/jmap')) return jsonResponse(SESSION);
|
|
const body = JSON.parse(init?.body ?? '{}') as { methodCalls: [string, Record<string, unknown>, string][] };
|
|
const first = body.methodCalls[0];
|
|
if (first[0] === 'Mailbox/query') {
|
|
return jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] });
|
|
}
|
|
return jsonResponse({
|
|
methodResponses: [
|
|
['Email/query', { ids: ['e1'], total: 1 }, 'eq'],
|
|
['Email/get', { list: [{ id: 'e1', threadId: 't1', subject: 'Hi' }] }, 'eg'],
|
|
],
|
|
});
|
|
});
|
|
}
|
|
|
|
async function callRoute(accountId: string, emailId: string | null = null) {
|
|
const { GET } = await import('@/app/api/push/preview/route');
|
|
const request = {
|
|
headers: new Headers(),
|
|
nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? emailId : null) } },
|
|
};
|
|
const res = (await GET(request as unknown as Parameters<typeof GET>[0])) as unknown as {
|
|
status: number;
|
|
json: () => Promise<Record<string, unknown>>;
|
|
};
|
|
return { status: res.status, body: await res.json() };
|
|
}
|
|
|
|
describe('push preview route account resolution', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockJmap();
|
|
});
|
|
|
|
it('resolves the primary mail account', async () => {
|
|
const { status } = await callRoute('a');
|
|
expect(status).toBe(200);
|
|
});
|
|
|
|
it('resolves a shared/group account listed in session.accounts', async () => {
|
|
const { status, body } = await callRoute('g');
|
|
|
|
expect(status).not.toBe(401);
|
|
expect(status).toBe(200);
|
|
expect(body.email).toMatchObject({ id: 'e1' });
|
|
|
|
// The Inbox lookup must be scoped to the shared account, not the primary.
|
|
const mailboxQuery = fetchJmapServer.mock.calls
|
|
.map(([, init]) => (init as { body?: string })?.body)
|
|
.filter((b): b is string => typeof b === 'string')
|
|
.map((b) => JSON.parse(b) as { methodCalls: [string, Record<string, unknown>, string][] })
|
|
.find((b) => b.methodCalls[0][0] === 'Mailbox/query');
|
|
expect(mailboxQuery?.methodCalls[0][1].accountId).toBe('g');
|
|
});
|
|
|
|
it('names the login for a message, as the action route knows it', async () => {
|
|
const { body } = await callRoute('a');
|
|
expect(body.login).toBe(await loginFingerprint('https://mail.example.com', undefined));
|
|
});
|
|
|
|
it('names the login (cookie slot) the account belongs to', async () => {
|
|
// The worker only knows the JMAP account id, which can repeat across
|
|
// servers; the click must open the message in this login.
|
|
const { body } = await callRoute('a');
|
|
expect(body.slot).toBe(0);
|
|
});
|
|
|
|
it('rejects an account the session does not know', async () => {
|
|
const { status } = await callRoute('stranger');
|
|
expect(status).toBe(401);
|
|
});
|
|
});
|
|
|
|
|
|
describe('push preview JMAP failures', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockJmap();
|
|
});
|
|
|
|
it.each([
|
|
['error', { type: 'unsupportedFilter' }, 'mb'],
|
|
['Mailbox/query', {}, 'mb'],
|
|
])('does not silence a push when mailbox lookup fails (%s)', async (method, body, id) => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [[method, body, id]] }));
|
|
expect((await callRoute('a')).status).toBe(502);
|
|
});
|
|
|
|
it.each([
|
|
[['error', { type: 'serverFail' }, 'eq'], ['error', { type: 'resultReference' }, 'eg']],
|
|
[['Email/query', { ids: ['e1'], total: 1 }, 'eq'], ['error', { type: 'serverFail' }, 'eg']],
|
|
])('does not report zero unread on an email method failure', async (query, get) => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [query, get] }));
|
|
expect((await callRoute('a')).status).toBe(502);
|
|
});
|
|
|
|
it('still reports genuinely empty unread results', async () => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['Email/query', { ids: [], total: 0 }, 'eq'],
|
|
['Email/get', { list: [] }, 'eg'],
|
|
] }));
|
|
expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } });
|
|
});
|
|
|
|
// `total` is optional in a JMAP Email/query response.
|
|
it('counts the ids when the server leaves out the total', async () => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['Email/query', { ids: [] }, 'eq'],
|
|
['Email/get', { list: [] }, 'eg'],
|
|
] }));
|
|
expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } });
|
|
});
|
|
|
|
it('previews the named message even when the Inbox lookups fail', async () => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['error', { type: 'serverFail' }, 'eq'],
|
|
['error', { type: 'resultReference' }, 'eg'],
|
|
['Email/get', { list: [{ id: 'd', threadId: 'td' }] }, 'delivered'],
|
|
] }));
|
|
expect(await callRoute('a', 'd')).toEqual({
|
|
status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0, login: expect.any(String) },
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
it('previews a delivered message outside an empty Inbox', async () => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['Email/query', { ids: [], total: 0 }, 'eq'],
|
|
['Email/get', { list: [] }, 'eg'],
|
|
['Email/get', { list: [{ id: 'filed', threadId: 't2' }] }, 'delivered'],
|
|
] }));
|
|
expect(await callRoute('a', 'filed')).toEqual({
|
|
status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0, login: expect.any(String) },
|
|
});
|
|
});
|
|
|
|
it('names no message when the delivered one cannot be read, rather than another', async () => {
|
|
// The push named a message - deleted since, or filed where this login
|
|
// cannot see it - and the Inbox happens to hold an older unread one. That
|
|
// older message is not what arrived.
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['Email/query', { ids: ['stale'], total: 3 }, 'eq'],
|
|
['Email/get', { list: [{ id: 'stale', threadId: 't9' }] }, 'eg'],
|
|
['Email/get', { list: [] }, 'delivered'],
|
|
] }));
|
|
expect(await callRoute('a', 'gone')).toEqual({
|
|
status: 200, body: { email: null, unreadTotal: 3, slot: 0 },
|
|
});
|
|
});
|
|
|
|
it('sends the method calls to the server address it was configured with, not the public one', async () => {
|
|
// Stalwart advertises its own public hostname in apiUrl, which the webmail
|
|
// may not reach when it talks to the server over an internal address (#627).
|
|
fetchJmapServer.mockReset();
|
|
mockJmap();
|
|
const session = { ...SESSION, apiUrl: 'https://public.example.org/jmap/' };
|
|
const reply = fetchJmapServer.getMockImplementation()!;
|
|
fetchJmapServer.mockImplementation(async (url: string, init?: { body?: string }) =>
|
|
url.endsWith('/.well-known/jmap') ? jsonResponse(session) : reply(url, init));
|
|
|
|
expect((await callRoute('a')).status).toBe(200);
|
|
const posted = fetchJmapServer.mock.calls.map(([url]) => url).filter((url) => !url.endsWith('/.well-known/jmap'));
|
|
expect(posted).toEqual(['https://mail.example.com/jmap/', 'https://mail.example.com/jmap/']);
|
|
});
|
|
|
|
it('answers 502 instead of buffering a mailbox answer that never ends', async () => {
|
|
fetchJmapServer.mockReset();
|
|
mockJmap();
|
|
const reply = fetchJmapServer.getMockImplementation()!;
|
|
const endless = endlessResponse(500);
|
|
fetchJmapServer.mockImplementation(async (url: string, init?: { body?: string }) =>
|
|
url.endsWith('/.well-known/jmap') ? reply(url, init) : endless.response);
|
|
|
|
expect((await callRoute('a')).status).toBe(502);
|
|
expect(endless.pulled()).toBeLessThan(10);
|
|
});
|