mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: end every push with a notification so Safari keeps the subscription #1322
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
|
||||
// #1322: Safari drops the push subscription after three pushes that end
|
||||
// without showNotification, so every path through the push handler has to
|
||||
// show something, without a sound when there is nothing new.
|
||||
const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
|
||||
|
||||
type Shown = { title: string; body?: unknown; data?: unknown; options: Record<string, unknown>; close: () => void };
|
||||
|
||||
type Preview = { unreadTotal: number; email: Record<string, unknown> | null };
|
||||
|
||||
function loadWorker(preview: (url: string, init: { signal?: AbortSignal }) => Promise<Preview>) {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const shade = new Map<string, Shown>();
|
||||
const calls: Array<{ title: string; options: Record<string, unknown> }> = [];
|
||||
const cacheStore = new Map<string, string>();
|
||||
|
||||
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 () => [] },
|
||||
registration: {
|
||||
showNotification: async (title: string, options: Record<string, unknown>) => {
|
||||
const tag = String(options.tag ?? '');
|
||||
calls.push({ title, options });
|
||||
shade.set(tag, { title, body: options.body, data: options.data, options, close: () => shade.delete(tag) });
|
||||
},
|
||||
getNotifications: async ({ tag }: { tag: string }) => (shade.has(tag) ? [shade.get(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 (url: string, init: { signal?: AbortSignal }) => {
|
||||
const body = await preview(url, init);
|
||||
return { ok: true, json: async () => body };
|
||||
});
|
||||
|
||||
const context = vm.createContext({
|
||||
self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console,
|
||||
AbortController, setTimeout, clearTimeout,
|
||||
});
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
|
||||
async function push(payload: unknown) {
|
||||
const waits: Promise<unknown>[] = [];
|
||||
listeners.get('push')?.({
|
||||
data: { json: () => payload },
|
||||
waitUntil: (p: Promise<unknown>) => waits.push(p),
|
||||
});
|
||||
await Promise.all(waits);
|
||||
}
|
||||
return { push, shade, calls };
|
||||
}
|
||||
|
||||
const mail = (id: string) => ({ id, threadId: 't-' + id, from: [{ name: 'Alice' }], subject: 'Hello', preview: '' });
|
||||
const delivery = (id: string) => ({ kind: 'jmap-email-push', accountId: 'a', emailIds: [id] });
|
||||
|
||||
describe('service worker never ends a push without a notification (#1322)', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('re-shows the notification silently when a push is redelivered', async () => {
|
||||
const { push, shade, calls } = loadWorker(async () => ({ unreadTotal: 1, email: mail('e1') }));
|
||||
await push(delivery('e1'));
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
await push(delivery('e1'));
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].title).toBe('Alice');
|
||||
expect(calls[1].options).toMatchObject({ tag: 'bulwark-mail:a', body: 'Hello', renotify: false, silent: true });
|
||||
expect(shade.get('bulwark-mail:a')?.title).toBe('Alice');
|
||||
});
|
||||
|
||||
it('shows and removes a notification when nothing is unread and none is on screen', async () => {
|
||||
const { push, shade, calls } = loadWorker(async () => ({ unreadTotal: 0, email: null }));
|
||||
await push({ kind: 'jmap-state-change', changed: { a: {} } });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].options).toMatchObject({ renotify: false, silent: true });
|
||||
expect(shade.size).toBe(0);
|
||||
});
|
||||
|
||||
it('re-shows silently when the newest unread mail was already announced', async () => {
|
||||
const { push, calls } = loadWorker(async () => ({ unreadTotal: 1, email: mail('e1') }));
|
||||
await push(delivery('e1'));
|
||||
await push({ kind: 'jmap-state-change', changed: { a: {} } });
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].options).toMatchObject({ renotify: false, silent: true });
|
||||
});
|
||||
|
||||
it('gives up on a slow preview and shows the generic notification in time', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { push, calls } = loadWorker((_url, init) => new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
}));
|
||||
const done = push(delivery('e1'));
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await done;
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].title).toBe('New mail');
|
||||
});
|
||||
});
|
||||
+58
-8
@@ -126,7 +126,9 @@ async function handlePush(event) {
|
||||
// re-buzz for that old message.
|
||||
const notified = await readNotifiedIds(accountId);
|
||||
const freshIds = emailIds.filter((id) => !notified.includes(id));
|
||||
const groupTag = "bulwark-mail:" + (accountId || "default");
|
||||
if (emailIds.length > 0 && freshIds.length === 0) {
|
||||
await showWithoutAlert(groupTag);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,16 +145,26 @@ async function handlePush(event) {
|
||||
if (freshIds.length > 0) query.set("emailId", freshIds[freshIds.length - 1]);
|
||||
const qs = query.toString();
|
||||
const previewUrl = `${BASE_PATH}/api/push/preview${qs ? `?${qs}` : ""}`;
|
||||
const res = await fetch(previewUrl, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.ok) {
|
||||
preview = await res.json();
|
||||
previewOk = true;
|
||||
// A notification shown more than 30 s after the push counts as a silent
|
||||
// push in Safari (#1322), so a slow preview gives way to the generic one.
|
||||
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), PREVIEW_DEADLINE_MS) : null;
|
||||
try {
|
||||
const res = await fetch(previewUrl, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
});
|
||||
if (res.ok) {
|
||||
preview = await res.json();
|
||||
previewOk = true;
|
||||
}
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
} catch (_) {
|
||||
preview = null;
|
||||
previewOk = false;
|
||||
}
|
||||
|
||||
const email = preview && preview.email ? preview.email : null;
|
||||
@@ -170,6 +182,7 @@ async function handlePush(event) {
|
||||
// failed (network/auth/server down) we cannot tell, so fall through to the
|
||||
// generic "New mail" toast rather than miss a real delivery.
|
||||
if (previewOk && !email && unreadTotal === 0) {
|
||||
await showWithoutAlert(groupTag);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,6 +190,7 @@ async function handlePush(event) {
|
||||
// message is one we already announced, so whatever woke us was not new
|
||||
// Inbox mail - typically a delivery into Junk or a Sieve-filed folder.
|
||||
if (emailIds.length === 0 && previewOk && email && notified.includes(email.id)) {
|
||||
await showWithoutAlert(groupTag);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,7 +200,6 @@ async function handlePush(event) {
|
||||
// used to stack 50 toasts). The newest message is the headline and a
|
||||
// Gmail-style "+N more" line carries the rest, counted from the account's
|
||||
// unread total.
|
||||
const groupTag = "bulwark-mail:" + (accountId || "default");
|
||||
let title;
|
||||
let body;
|
||||
let data = { kind: "mail-list", accountId, slot };
|
||||
@@ -234,6 +247,43 @@ async function handlePush(event) {
|
||||
}
|
||||
}
|
||||
|
||||
// How long the preview may take before the generic notification is shown.
|
||||
const PREVIEW_DEADLINE_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Every push has to end in showNotification. Safari counts a push without one
|
||||
* as silent and drops the subscription after three (WebKit maxSilentPushCount);
|
||||
* Firefox and Chrome spend a quota or show a notice of their own. A push with
|
||||
* nothing new to say (a redelivery, mail already read or already announced)
|
||||
* re-shows the account's notification as it is, without a sound. With none on
|
||||
* screen, it shows one and takes it away again at once (#1322).
|
||||
*/
|
||||
async function showWithoutAlert(groupTag) {
|
||||
const options = {
|
||||
tag: groupTag,
|
||||
icon: `${BASE_PATH}/api/pwa-icon/192`,
|
||||
badge: `${BASE_PATH}/api/pwa-icon/192`,
|
||||
renotify: false,
|
||||
silent: true,
|
||||
};
|
||||
try {
|
||||
const existing = (await self.registration.getNotifications({ tag: groupTag }))[0];
|
||||
if (existing) {
|
||||
await self.registration.showNotification(existing.title, {
|
||||
...options,
|
||||
body: existing.body,
|
||||
data: existing.data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await self.registration.showNotification("New mail", { ...options, data: { kind: "mail-list" } });
|
||||
const shown = await self.registration.getNotifications({ tag: groupTag });
|
||||
for (const notification of shown) notification.close();
|
||||
} catch (_) {
|
||||
// Nothing more a worker can do.
|
||||
}
|
||||
}
|
||||
|
||||
// One message often lands in several of the user's accounts at once - a list,
|
||||
// a forward, the same newsletter on two logins - and each account has its own
|
||||
// notification. Only the first alert of a burst makes a sound; other accounts'
|
||||
|
||||
Reference in New Issue
Block a user