* Added account selection for protocol links when multiple connected accounts are available, including mailto: links * Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab * Added webcal: protocol handling for calendar links * Added account selection for webcal: links when multiple calendar-capable accounts are connected * Added an import-or-subscribe choice for detected webcal calendars * Added protocol handler settings for registering mail and calendar handlers and choosing the open mode * Added service worker/session coordination for passing protocol requests between browser/PWA contexts * Added tests and translations for the new protocol handler flows
330 lines
10 KiB
JavaScript
330 lines
10 KiB
JavaScript
/* eslint-disable no-undef */
|
|
|
|
// Bulwark service worker.
|
|
//
|
|
// This SW does two jobs:
|
|
// 1. Satisfy the PWA installability requirement (network-only fetch handler,
|
|
// no caching - so we never serve stale chunks after a deployment).
|
|
// 2. Receive Web Push wake-up pings from the relay and turn them into
|
|
// enriched system notifications. Mirrors the React Native FCM headless
|
|
// task: relay sends only a state-change ping, the client fetches the
|
|
// newest unread email itself so the relay never sees mail content.
|
|
|
|
// When the app is mounted at a subpath (Next.js basePath, e.g. /webmail), the
|
|
// SW is served at /webmail/sw.js and registered with scope /webmail/. Derive
|
|
// the prefix from the SW's own URL so push fetches and notification clicks
|
|
// land on the right path - service workers can't read process.env.
|
|
function getBasePath() {
|
|
const path = new URL(self.location.href).pathname;
|
|
// self.location is .../sw.js; strip the trailing filename to get the dir,
|
|
// then strip the trailing slash so it concatenates cleanly with `/foo`.
|
|
const dir = path.replace(/[^/]*$/, "");
|
|
return dir.replace(/\/+$/, "");
|
|
}
|
|
|
|
const BASE_PATH = getBasePath();
|
|
const MAILTO_CLIENTS = new Map();
|
|
|
|
self.addEventListener("install", () => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(self.clients.claim());
|
|
});
|
|
|
|
self.addEventListener("fetch", () => {});
|
|
|
|
self.addEventListener("push", (event) => {
|
|
event.waitUntil(handlePush(event));
|
|
});
|
|
|
|
self.addEventListener("notificationclick", (event) => {
|
|
event.notification.close();
|
|
event.waitUntil(handleNotificationClick(event));
|
|
});
|
|
|
|
self.addEventListener("message", (event) => {
|
|
const data = event.data || {};
|
|
if (data.type === "mailto-client-ready") {
|
|
if (event.source && event.source.id) {
|
|
MAILTO_CLIENTS.set(event.source.id, {
|
|
path: typeof data.path === "string" ? data.path : "",
|
|
standalone: data.standalone === true,
|
|
clientId: typeof data.clientId === "string" ? data.clientId : "",
|
|
focusNotificationTitle: typeof data.focusNotificationTitle === "string" ? data.focusNotificationTitle : "",
|
|
focusNotificationBody: typeof data.focusNotificationBody === "string" ? data.focusNotificationBody : "",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.type === "mailto-client-gone") {
|
|
if (event.source && event.source.id) {
|
|
const current = MAILTO_CLIENTS.get(event.source.id);
|
|
if (!current
|
|
|| (typeof data.clientId === "string" && current.clientId === data.clientId)
|
|
|| (typeof data.clientId !== "string" && typeof data.path === "string" && current.path === data.path)) {
|
|
MAILTO_CLIENTS.delete(event.source.id);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.type === "open-mailto-in-client") {
|
|
event.waitUntil(handleOpenMailtoInClient(event));
|
|
return;
|
|
}
|
|
|
|
if (data.type === "focus-existing-mailto-client") {
|
|
event.waitUntil(focusExistingWindowClient(event.source && event.source.id, true));
|
|
return;
|
|
}
|
|
|
|
if (data.type !== "focus-existing-client") return;
|
|
|
|
event.waitUntil(focusExistingWindowClient(event.source && event.source.id));
|
|
});
|
|
|
|
async function handlePush(event) {
|
|
let payload = null;
|
|
try {
|
|
payload = event.data ? event.data.json() : null;
|
|
} catch (_) {
|
|
payload = null;
|
|
}
|
|
|
|
const accountLabel = (payload && typeof payload.accountLabel === "string")
|
|
? payload.accountLabel
|
|
: "";
|
|
|
|
// Best effort: ask the webmail to look up the latest unread email so we can
|
|
// build a useful notification. If the request fails (offline, session
|
|
// expired, server down) we fall back to a generic "New mail" so the user
|
|
// still sees something.
|
|
let preview = null;
|
|
let previewOk = false;
|
|
try {
|
|
const res = await fetch(`${BASE_PATH}/api/push/preview`, {
|
|
credentials: "include",
|
|
cache: "no-store",
|
|
});
|
|
if (res.ok) {
|
|
preview = await res.json();
|
|
previewOk = true;
|
|
}
|
|
} catch (_) {
|
|
preview = null;
|
|
}
|
|
|
|
const email = preview && preview.email ? preview.email : null;
|
|
const unreadTotal = preview && typeof preview.unreadTotal === "number"
|
|
? preview.unreadTotal
|
|
: 0;
|
|
|
|
// Push subscription is scoped to EmailDelivery, but stragglers from the
|
|
// older broader-types subscription, marking-as-read races and verification
|
|
// pings can still wake us with no actual unread mail. When the preview API
|
|
// succeeded and reports zero unread, stay silent. When the preview API
|
|
// 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) {
|
|
return;
|
|
}
|
|
|
|
let title;
|
|
let body;
|
|
let tag = "bulwark-mail";
|
|
let data = { kind: "mail-list" };
|
|
|
|
if (email) {
|
|
const sender = email.from && email.from[0];
|
|
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
|
|
title = senderName + (accountLabel ? ` (${accountLabel})` : "");
|
|
body = email.subject || email.preview || "(no subject)";
|
|
tag = "bulwark-mail:" + email.id;
|
|
data = {
|
|
kind: "email",
|
|
emailId: email.id,
|
|
threadId: email.threadId,
|
|
};
|
|
} else {
|
|
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
|
|
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
|
|
}
|
|
|
|
await self.registration.showNotification(title, {
|
|
body,
|
|
tag,
|
|
icon: `${BASE_PATH}/icon-192x192.png`,
|
|
badge: `${BASE_PATH}/icon-192x192.png`,
|
|
data,
|
|
renotify: true,
|
|
});
|
|
}
|
|
|
|
async function handleNotificationClick(event) {
|
|
const data = event.notification.data || {};
|
|
const tag = event.notification.tag || "";
|
|
|
|
if (data.kind === "protocol-mailto-focus") {
|
|
return handleMailtoFocusNotificationClick();
|
|
}
|
|
|
|
const targetUrl = buildClickUrl(data);
|
|
|
|
const allClients = await self.clients.matchAll({
|
|
type: "window",
|
|
includeUncontrolled: true,
|
|
});
|
|
|
|
// Notify any in-app clients so plugins listening on toastHooks.onNotificationClick fire.
|
|
for (const client of allClients) {
|
|
try {
|
|
client.postMessage({ kind: "notificationclick", tag, data });
|
|
} catch (_) {
|
|
// Closed or detached client - ignore.
|
|
}
|
|
}
|
|
|
|
for (const client of allClients) {
|
|
// Reuse an existing tab whenever possible - users on desktop browsers
|
|
// get annoyed when each notification opens a fresh window.
|
|
if ("focus" in client) {
|
|
try {
|
|
if ("navigate" in client && targetUrl) {
|
|
await client.navigate(targetUrl);
|
|
}
|
|
return client.focus();
|
|
} catch (_) {
|
|
// navigate() can reject for cross-origin or detached clients - fall
|
|
// through and open a new window below.
|
|
}
|
|
}
|
|
}
|
|
|
|
if (self.clients.openWindow) {
|
|
return self.clients.openWindow(targetUrl || `${BASE_PATH}/`);
|
|
}
|
|
}
|
|
|
|
async function focusExistingWindowClient(sourceClientId, requireMailtoReady) {
|
|
const entry = await findReusableWindowClientEntry(sourceClientId, requireMailtoReady);
|
|
const client = entry && entry.client;
|
|
if (client && "focus" in client) {
|
|
return client.focus();
|
|
}
|
|
}
|
|
|
|
async function handleMailtoFocusNotificationClick() {
|
|
const entry = await findReusableWindowClientEntry(null, true);
|
|
const client = entry && entry.client;
|
|
if (client && "focus" in client) {
|
|
try {
|
|
return await client.focus();
|
|
} catch (_) {
|
|
// Fall through to opening a new app window if activation is still blocked.
|
|
}
|
|
}
|
|
|
|
if (self.clients.openWindow) {
|
|
return self.clients.openWindow(`${BASE_PATH}/`);
|
|
}
|
|
}
|
|
|
|
async function handleOpenMailtoInClient(event) {
|
|
const data = event.data || {};
|
|
const responsePort = event.ports && event.ports[0];
|
|
const entry = await findReusableWindowClientEntry(event.source && event.source.id, true);
|
|
const client = entry && entry.client;
|
|
const state = entry && entry.state;
|
|
|
|
if (!client || !state || !state.clientId) {
|
|
responsePort && responsePort.postMessage({ delivered: false });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
client.postMessage({ type: "mailto-request", id: data.id, clientId: state.clientId, value: data.value });
|
|
} catch (_) {
|
|
responsePort && responsePort.postMessage({ delivered: false });
|
|
return;
|
|
}
|
|
|
|
if ("focus" in client) {
|
|
try {
|
|
await client.focus();
|
|
} catch (_) {
|
|
// Delivery succeeded; focusing can still be blocked by browser policy.
|
|
await showMailtoFocusNotification(state);
|
|
}
|
|
}
|
|
|
|
responsePort && responsePort.postMessage({ delivered: true });
|
|
}
|
|
|
|
async function showMailtoFocusNotification(state) {
|
|
try {
|
|
await self.registration.showNotification(state.focusNotificationTitle || "Bulwark", {
|
|
body: state.focusNotificationBody || "The request was opened in Bulwark. Click to bring it to the front.",
|
|
tag: "bulwark-mailto-focus",
|
|
icon: `${BASE_PATH}/icon-192x192.png`,
|
|
badge: `${BASE_PATH}/icon-192x192.png`,
|
|
data: { kind: "protocol-mailto-focus" },
|
|
renotify: true,
|
|
});
|
|
} catch (_) {
|
|
// Notification permission may be missing; the mailto request was still delivered.
|
|
}
|
|
}
|
|
|
|
async function findReusableWindowClientEntry(sourceClientId, requireMailtoReady) {
|
|
const scopedPath = BASE_PATH ? `${BASE_PATH}/` : "/";
|
|
const allClients = await self.clients.matchAll({
|
|
type: "window",
|
|
includeUncontrolled: true,
|
|
});
|
|
const candidates = [];
|
|
|
|
for (const client of allClients) {
|
|
if (client.id === sourceClientId) continue;
|
|
const state = MAILTO_CLIENTS.get(client.id);
|
|
if (requireMailtoReady && !state) continue;
|
|
|
|
try {
|
|
const url = new URL(client.url);
|
|
if (url.origin !== self.location.origin) continue;
|
|
if (!url.pathname.startsWith(scopedPath)) continue;
|
|
if (url.pathname.includes("/protocol/")) continue;
|
|
|
|
candidates.push({ client, state, score: getReusableClientScore(state) });
|
|
} catch (_) {
|
|
// Detached clients can disappear while iterating.
|
|
}
|
|
}
|
|
|
|
candidates.sort((a, b) => a.score - b.score);
|
|
return candidates[0];
|
|
}
|
|
|
|
function getReusableClientScore(state) {
|
|
if (!state) return 4;
|
|
|
|
const isMailSection = state.path === "/" || state.path === "";
|
|
if (state.standalone && isMailSection) return 0;
|
|
if (isMailSection) return 1;
|
|
if (state.standalone) return 2;
|
|
return 3;
|
|
}
|
|
|
|
function buildClickUrl(data) {
|
|
if (!data) return `${BASE_PATH}/`;
|
|
if (data.kind === "email" && data.emailId) {
|
|
return `${BASE_PATH}/?email=${encodeURIComponent(data.emailId)}`;
|
|
}
|
|
// 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
|
|
// app shell, so the click still feels purposeful.
|
|
return `${BASE_PATH}/?openLatestUnread=1`;
|
|
}
|