mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: notify for every account a state-change push names
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// What one new message, several at once and several accounts turn into on screen.
|
||||
const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
|
||||
|
||||
type Preview = Record<string, unknown> | null;
|
||||
type Shown = { title: string; body?: unknown; data?: Record<string, unknown>; tag: string; options: Record<string, unknown>; close: () => void };
|
||||
|
||||
function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record<string, unknown>; prefs?: unknown; openWindow?: (url: string) => Promise<unknown> } = {}) {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const shade = new Map<string, Shown>();
|
||||
const previews: URL[] = [];
|
||||
const cacheStore = new Map<string, string>();
|
||||
if (extra.prefs) cacheStore.set('https://mail.example.com/__push-state/prefs', JSON.stringify(extra.prefs));
|
||||
const self = {
|
||||
location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' },
|
||||
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
|
||||
skipWaiting: () => {},
|
||||
clients: { claim: () => {}, matchAll: async () => extra.clients ?? [], openWindow: extra.openWindow },
|
||||
registration: {
|
||||
showNotification: async (title: string, options: Record<string, unknown>) => {
|
||||
const tag = String(options.tag ?? '');
|
||||
shade.set(tag, { title, body: options.body, data: options.data as Record<string, unknown>, tag, options, close: () => shade.delete(tag) });
|
||||
},
|
||||
getNotifications: async (filter?: { tag?: string }) => [...shade.values()].filter((n) => !filter?.tag || n.tag === filter.tag),
|
||||
},
|
||||
};
|
||||
const caches = {
|
||||
open: async () => ({
|
||||
match: async (key: string) => {
|
||||
const value = cacheStore.get(key);
|
||||
return value === undefined ? undefined : { json: async () => JSON.parse(value) };
|
||||
},
|
||||
put: async (key: string, res: { text: () => Promise<string> }) => { cacheStore.set(key, await res.text()); },
|
||||
}),
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: string) => {
|
||||
const url = new URL(input, 'https://mail.example.com');
|
||||
previews.push(url);
|
||||
const body = preview(url);
|
||||
return body ? { ok: true, json: async () => body } : { ok: false, status: 401, json: async () => ({}) };
|
||||
});
|
||||
const context = vm.createContext({ self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console, AbortController, setTimeout, clearTimeout, ...extra.globals });
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
async function dispatch(type: string, event: Record<string, unknown>) {
|
||||
const waits: Promise<unknown>[] = [];
|
||||
listeners.get(type)?.({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
|
||||
await Promise.all(waits);
|
||||
}
|
||||
const push = (payload: unknown) => dispatch('push', { data: { json: () => payload } });
|
||||
return { push, dispatch, shade, previews };
|
||||
}
|
||||
|
||||
const mail = (id: string, subject = 'Hello') => ({ id, threadId: `t-${id}`, from: [{ name: 'Alice' }], subject, preview: '' });
|
||||
const delivery = (accountId: string, ...emailIds: string[]) => ({ kind: 'jmap-email-push', accountLabel: 'me@example.org', accountId, emailIds });
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('a state-change push naming several accounts', () => {
|
||||
const stateChange = (changed: Record<string, Record<string, string>>, accountId = Object.keys(changed)[0]) => ({
|
||||
kind: 'jmap-state-change', accountLabel: 'me@example.org', accountId, emailIds: [], changed,
|
||||
});
|
||||
|
||||
it('notifies for each account with new mail', async () => {
|
||||
const { push, shade, previews } = loadWorker((url) => ({ unreadTotal: 1, slot: 0, email: mail(`m-${url.searchParams.get('accountId')}`) }));
|
||||
await push(stateChange({ me: { EmailDelivery: 's1', Email: 's1' }, team: { EmailDelivery: 's2' } }));
|
||||
expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me', 'team']);
|
||||
expect([...shade.keys()].sort()).toEqual(['bulwark-mail:me', 'bulwark-mail:team']);
|
||||
expect(shade.get('bulwark-mail:team')?.data).toMatchObject({ accountId: 'team', emailId: 'm-team' });
|
||||
});
|
||||
|
||||
it('leaves out an account that only changed some other way', async () => {
|
||||
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
|
||||
await push(stateChange({ quiet: { Mailbox: 's1' }, me: { EmailDelivery: 's2' } }));
|
||||
expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']);
|
||||
});
|
||||
|
||||
it('still shows something for a ping without new mail in it', async () => {
|
||||
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
|
||||
await push(stateChange({ me: { Mailbox: 's1' } }));
|
||||
expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']);
|
||||
});
|
||||
});
|
||||
|
||||
// -- end of cases --
|
||||
+12
-3
@@ -589,17 +589,26 @@ async function handlePush(event) {
|
||||
// message ids that passed it - spam filed into Junk never gets here.
|
||||
// - "jmap-state-change" (older servers): a bare EmailDelivery ping wrapped
|
||||
// as { changed: { [accountId]: {...} } }, fired for every ingested
|
||||
// message including junk. The relay forwards a single account per push,
|
||||
// so the first key is the one this notification is for.
|
||||
// message including junk. Stalwart batches what changes between two
|
||||
// pushes, so it can name the user's own account and a group's at once.
|
||||
const changed = payload && payload.changed && typeof payload.changed === "object"
|
||||
? payload.changed
|
||||
: null;
|
||||
const accountId = (payload && typeof payload.accountId === "string" && payload.accountId)
|
||||
const named = (payload && typeof payload.accountId === "string" && payload.accountId)
|
||||
|| (changed ? Object.keys(changed)[0] || "" : "");
|
||||
const emailIds = payload && Array.isArray(payload.emailIds)
|
||||
? payload.emailIds.filter((id) => typeof id === "string" && id)
|
||||
: [];
|
||||
|
||||
const withNewMail = emailIds.length > 0 || !changed
|
||||
? []
|
||||
: Object.keys(changed).filter((id) => changed[id] && typeof changed[id] === "object" && "EmailDelivery" in changed[id]);
|
||||
for (const accountId of withNewMail.length > 0 ? withNewMail : [named]) {
|
||||
await notifyAccount(accountId, emailIds, accountLabel);
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyAccount(accountId, emailIds, accountLabel) {
|
||||
// Never notify twice for the same message. A push can be redelivered (relay
|
||||
// retry, browser replay after coming back online) and, on the older
|
||||
// state-change path, a junk delivery wakes us while an unread message we
|
||||
|
||||
Reference in New Issue
Block a user