The service worker answers app routes from its kept page (#395), and it kept whatever the mount's root returned at install and whatever HTML a navigation returned. Where the root is not the app -- demo.ihasmail.com puts its landing page there -- a returning visitor got the landing page on every route. The kept page is now only ever the app page, recognised by the asset list the build writes into it: install fetches /mail instead of /, a navigation's page is kept only if it is the app's, and a foreign page left by the earlier worker is dropped when this one activates. Only the app's own routes are answered from it; the root and any page in front of the app go to the network. The reload for a new build primes the kept page from /mail for the same reason.
573 lines
24 KiB
JavaScript
573 lines
24 KiB
JavaScript
/* ihasmail service worker.
|
|
Two jobs: app-shell caching for installability and fast loads (API requests
|
|
are never cached), and Web Push, which is the only part of ihasmail that runs
|
|
when no tab is open. */
|
|
const VERSION = "ihasmail-v2";
|
|
|
|
/*
|
|
* The mount, worked out rather than configured.
|
|
*
|
|
* This file is copied to the build verbatim -- Vite's `base` never touches
|
|
* public/ -- so there is nothing to substitute BASE_PATH into. It does not
|
|
* need one: the worker is served from the mount, so its own address says
|
|
* where that is. `/mail/sw.js` gives `/mail`, `/sw.js` gives `""`, which is
|
|
* the same canonical form the rest of the app uses.
|
|
*
|
|
* Deriving it here also means the worker cannot disagree with the page that
|
|
* registered it, which a second copy of the value in a build-time constant
|
|
* eventually would.
|
|
*/
|
|
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
|
|
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
|
|
|
|
/*
|
|
* Only the app page may be kept as the app page.
|
|
*
|
|
* The mount's root is not always the app: demo.ihasmail.com puts its landing
|
|
* page there, and a front door of any kind can. The worker used to cache
|
|
* whatever `/` returned at install and whatever HTML a navigation returned,
|
|
* and since app routes are answered from that copy first, a demo visitor who
|
|
* came back got the landing page on every route, for good. The app page is
|
|
* recognised by the asset list the build writes into it.
|
|
*/
|
|
const APP_PAGE_MARKER = 'id="ihasmail-assets"';
|
|
const isAppPage = (html) => typeof html === "string" && html.includes(APP_PAGE_MARKER);
|
|
|
|
/*
|
|
* The routes the app itself owns (App.tsx). Only these are answered from the
|
|
* kept page; anything else under the mount -- the root, a landing or farewell
|
|
* page in front of the app, a file -- goes to the network as it always did.
|
|
*/
|
|
const APP_ROUTE = /^\/(mail|search|contacts|calendar|files|settings|admin|login)(\/|$)/;
|
|
|
|
self.addEventListener("install", (event) => {
|
|
event.waitUntil(
|
|
caches.open(VERSION)
|
|
.then((c) => c.addAll(SHELL))
|
|
.then(() => fetch(`${BASE}/mail`, { credentials: "same-origin" }).then((res) => (res.ok ? refreshShell(res) : undefined)).catch(() => {}))
|
|
.then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
caches.keys()
|
|
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
|
|
.then(() => dropForeignShell())
|
|
.then(() => tidy())
|
|
.catch(() => {})
|
|
.then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
/*
|
|
* Keeping the cache to what the current build uses.
|
|
*
|
|
* Build assets are cached on first use and their names change with every
|
|
* build, and nothing used to take them out again: every deploy's chunks stayed
|
|
* in the browser for good. Worse, whatever the server answered was kept -- a
|
|
* 404 for a chunk asked for while a deploy was changing over became that
|
|
* chunk, from then on, in that browser.
|
|
*
|
|
* The rule now: only a successful response is cached, and whenever the app
|
|
* page changes, the assets it no longer names are dropped. A lazily loaded
|
|
* chunk the page does not name is dropped too, and fetched again the next time
|
|
* it is wanted -- a hash that did not change is still on the server.
|
|
*
|
|
* The cache name stays as it is. The same cache carries what the worker leaves
|
|
* for a tab to collect -- a push verification, a share, the facts it notifies
|
|
* from -- and a new name would throw those away along with the rubbish.
|
|
*/
|
|
const ASSETS = `${BASE}/assets/`;
|
|
const SHELL_KEY = `${BASE}/`;
|
|
|
|
function assetsNamedIn(html) {
|
|
const out = new Set();
|
|
for (const m of html.matchAll(/["']([^"']*\/assets\/[^"']+)["']/g)) {
|
|
try {
|
|
out.add(new URL(m[1], self.location).pathname);
|
|
} catch {
|
|
/* not a URL */
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Drop failed responses, and assets the cached app page does not name. `also`
|
|
* is a page whose assets are kept as well: the one just replaced, which a tab
|
|
* opened from the kept copy may still be running.
|
|
*/
|
|
async function tidy(also = "") {
|
|
const cache = await caches.open(VERSION);
|
|
const shell = await cache.match(SHELL_KEY);
|
|
// Without a page to go by, which assets are current is unknown; keep them.
|
|
const keep = shell ? assetsNamedIn(await shell.text()) : null;
|
|
if (keep) for (const path of assetsNamedIn(also)) keep.add(path);
|
|
for (const req of await cache.keys()) {
|
|
const path = new URL(req.url).pathname;
|
|
if (path.startsWith(ASSETS)) {
|
|
if (keep && !keep.has(path)) {
|
|
await cache.delete(req);
|
|
continue;
|
|
}
|
|
}
|
|
const res = await cache.match(req);
|
|
if (res && !res.ok) await cache.delete(req);
|
|
}
|
|
}
|
|
|
|
/** A kept page that is not the app page -- left by an earlier worker -- is thrown away. */
|
|
async function dropForeignShell() {
|
|
const cache = await caches.open(VERSION);
|
|
const kept = await cache.match(SHELL_KEY);
|
|
if (kept && !isAppPage(await kept.text())) await cache.delete(SHELL_KEY);
|
|
}
|
|
|
|
/** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */
|
|
async function refreshShell(res) {
|
|
const html = await res.text();
|
|
if (!isAppPage(html)) return;
|
|
const cache = await caches.open(VERSION);
|
|
const prev = await cache.match(SHELL_KEY);
|
|
const prevHtml = prev ? await prev.text() : "";
|
|
if (prevHtml !== html) {
|
|
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
|
|
await tidy(prevHtml);
|
|
}
|
|
await precache(html);
|
|
}
|
|
|
|
/*
|
|
* Fetching the rest of the build before it is asked for.
|
|
*
|
|
* The app page lists every file of its build (see the asset-list plugin in
|
|
* vite.config.ts). Without this, the first time after a deploy that a reader
|
|
* opened the composer, settings or a viewer, it waited on the server for the
|
|
* code -- on a distant link, a visible pause. Now those files are fetched
|
|
* quietly once a page names them, a few at a time, and only those not held
|
|
* already; a load cut short is carried on at the next navigation, which calls
|
|
* this again. Language catalogs are left to be cached when used, and nothing
|
|
* is fetched ahead when the reader has asked the browser to save data.
|
|
*/
|
|
const PRECACHE_PARALLEL = 3;
|
|
|
|
function precacheList(html) {
|
|
const m = html.match(/<script type="application\/json" id="ihasmail-assets">([^<]*)<\/script>/);
|
|
if (!m) return [];
|
|
try {
|
|
const list = JSON.parse(m[1]).precache;
|
|
return Array.isArray(list) ? list.filter((p) => typeof p === "string" && p.startsWith(ASSETS)) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function precache(html) {
|
|
if (self.navigator.connection && self.navigator.connection.saveData) return;
|
|
const cache = await caches.open(VERSION);
|
|
const wanted = [];
|
|
for (const path of precacheList(html)) if (!(await cache.match(path))) wanted.push(path);
|
|
const next = async () => {
|
|
for (let path = wanted.shift(); path; path = wanted.shift()) {
|
|
try {
|
|
const res = await fetch(path, { credentials: "same-origin" });
|
|
if (res.ok) await cache.put(path, res);
|
|
} catch {
|
|
/* offline, or a deploy changing over; the next navigation tries again */
|
|
}
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: PRECACHE_PARALLEL }, next));
|
|
}
|
|
|
|
/*
|
|
* Where a share from the operating system is left for a tab to collect.
|
|
*
|
|
* Absolute and anchored to the mount, for the same reason the verification key
|
|
* below is: a relative key is resolved against the URL of whoever asks, and the
|
|
* worker and a tab deep in `/mail/inbox/…` are not at the same place.
|
|
*
|
|
* The files go in one entry each and the rest in a JSON index beside them,
|
|
* because the Cache API stores Responses and a File is already one body.
|
|
*/
|
|
const SHARE_KEY = `${BASE}/ihasmail-share`;
|
|
const SHARE_MAX_FILES = 20;
|
|
|
|
/*
|
|
* Take delivery of a share.
|
|
*
|
|
* This is a POST that navigates: the operating system submits a form at the
|
|
* app and expects a page back. Nothing in ihasmail can answer it directly --
|
|
* the app is a client-side router with no endpoint at that address, and the
|
|
* server behind it would have to grow one that understood the composer. So the
|
|
* worker takes the body, puts it where a tab can find it, and redirects to the
|
|
* app, which then opens a draft holding it.
|
|
*
|
|
* The redirect happens whatever went wrong. A share that fails to stash costs
|
|
* whatever was being shared, which is bad; a share that fails to *respond*
|
|
* costs that and leaves the reader looking at a browser error page where they
|
|
* expected their mail, which is worse.
|
|
*
|
|
* There is one case this cannot cover, and the server is deliberately not
|
|
* taught to: an app still installed whose worker has been cleared away. The
|
|
* POST then reaches the server, which answers 405, and the share is lost
|
|
* either way -- the payload only ever existed in that request body. A server
|
|
* route would trade a plain error for a silent nothing, and a share that
|
|
* vanishes without saying so is the harder of the two to notice.
|
|
*/
|
|
async function stashShare(request) {
|
|
try {
|
|
const form = await request.formData();
|
|
const cache = await caches.open(VERSION);
|
|
const meta = {
|
|
at: Date.now(),
|
|
title: String(form.get("title") ?? ""),
|
|
text: String(form.get("text") ?? ""),
|
|
url: String(form.get("url") ?? ""),
|
|
files: [],
|
|
};
|
|
const files = form.getAll("files").filter((f) => f && typeof f === "object" && "name" in f && f.size > 0);
|
|
for (const [i, f] of files.slice(0, SHARE_MAX_FILES).entries()) {
|
|
const key = `${SHARE_KEY}/${i}`;
|
|
await cache.put(key, new Response(f, { headers: { "content-type": f.type || "application/octet-stream" } }));
|
|
meta.files.push({ key, name: f.name || `file-${i + 1}`, type: f.type || "application/octet-stream" });
|
|
}
|
|
await cache.put(SHARE_KEY, new Response(JSON.stringify(meta), { headers: { "content-type": "application/json" } }));
|
|
} catch {
|
|
/* nothing to hand on: the app opens on an empty inbox rather than an error */
|
|
}
|
|
// Absolute, because `Response.redirect` rejects a bare path outright rather
|
|
// than resolving it -- so `${BASE}/mail` would throw here and the share
|
|
// would end at a browser error page instead of the inbox.
|
|
return Response.redirect(new URL(`${BASE}/mail?share=1`, self.location.origin).href, 303);
|
|
}
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const req = event.request;
|
|
if (req.method === "POST" && new URL(req.url).pathname === `${BASE}/share`) {
|
|
event.respondWith(stashShare(req));
|
|
return;
|
|
}
|
|
if (req.method !== "GET") return;
|
|
const url = new URL(req.url);
|
|
if (url.origin !== self.location.origin) return;
|
|
if (url.pathname.startsWith(`${BASE}/api/`)) return;
|
|
|
|
// Hashed build assets: cache-first, and only what actually arrived.
|
|
if (url.pathname.startsWith(ASSETS)) {
|
|
event.respondWith(
|
|
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
|
if (res.ok && res.type === "basic") {
|
|
const copy = res.clone();
|
|
event.waitUntil(caches.open(VERSION).then((c) => c.put(req, copy)).catch(() => {}));
|
|
}
|
|
return res;
|
|
}))
|
|
);
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Navigations: the kept app page at once, and the network's behind it.
|
|
*
|
|
* Every route in the app is the same page, and waiting on the server for it
|
|
* cost a full round trip before anything could start -- the longest single
|
|
* wait on a distant link. So a route is answered from the kept copy when
|
|
* there is one, and the fresh page is fetched alongside to replace it for
|
|
* next time. A page that is a build behind is caught the way it always was:
|
|
* the version check reloads it (lib/sw/staleBuild.ts), and the assets it
|
|
* names are kept for one more build so it can run until then.
|
|
*
|
|
* Only the app's own routes (APP_ROUTE). The root, a page in front of the
|
|
* app, and a file opened in a tab of its own go to the network as before. So
|
|
* does the first visit, which has no copy yet.
|
|
*/
|
|
if (req.mode === "navigate") {
|
|
const network = fetch(req).then((res) => {
|
|
// Every route is the same app page; a fresh one replaces the offline copy.
|
|
if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) {
|
|
event.waitUntil(refreshShell(res.clone()).catch(() => {}));
|
|
}
|
|
return res;
|
|
});
|
|
const appRoute = APP_ROUTE.test(url.pathname.slice(BASE.length));
|
|
event.respondWith((async () => {
|
|
const kept = appRoute ? await caches.match(SHELL_KEY) : undefined;
|
|
if (kept) {
|
|
event.waitUntil(network.catch(() => {}));
|
|
return kept;
|
|
}
|
|
return network.catch(() => caches.match(SHELL_KEY));
|
|
})());
|
|
return;
|
|
}
|
|
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
|
});
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Web Push */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/*
|
|
* Stalwart signs with VAPID and pushes straight to the browser's push service;
|
|
* nothing here talks to ihasmail's server on the way in. The payload is an
|
|
* EmailPush object (draft-ietf-jmap-emailpush) carrying enough of the message
|
|
* to show a useful notification without a round-trip, which is what lets a
|
|
* notification appear immediately rather than after a request.
|
|
*
|
|
* This file used to say that a round-trip was impossible here, and it was
|
|
* wrong: see the note on `jmap()`. What it can do is ask; what it cannot do is
|
|
* be sure of an answer, since the session may be gone by the time it does. So
|
|
* the payload still carries the message and the request is only made when
|
|
* somebody presses something.
|
|
*
|
|
* A JMAP subscription also delivers a PushVerification first, and stays silent
|
|
* until the client echoes its code back. It is stashed for a tab to confirm
|
|
* rather than answered here — on the same reasoning, and because a
|
|
* verification that failed silently would leave push looking broken with
|
|
* nothing to show for it. Answering it directly is now possible and is worth
|
|
* revisiting.
|
|
*/
|
|
|
|
/*
|
|
* Absolute, and anchored to the mount rather than to whatever page happens to
|
|
* be open.
|
|
*
|
|
* A relative key is resolved against the URL of whoever is asking: the worker
|
|
* lives at `<base>/sw.js`, so it stored this under `<base>/…`, while a tab at
|
|
* `/mail/inbox/abc` looked for it under `/mail/inbox/…`. The two only ever
|
|
* agreed when the open page was the root, so a verification code that arrived
|
|
* with no tab open was written where the next tab would not look -- and the
|
|
* subscription stayed silent, which is the same thing push failing looks like.
|
|
*/
|
|
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
|
|
|
|
/*
|
|
* What a tab wrote down for this worker: the account, which mailbox is the
|
|
* archive, and the worker's own text in the reader's language. See
|
|
* `lib/swFacts.ts` for why any of that has to be handed over rather than
|
|
* worked out here.
|
|
*
|
|
* Everything that depends on it is skipped when it is missing, which is the
|
|
* state between installing this worker and next opening the app. An action
|
|
* button with no label, or one that files mail into a mailbox guessed by name,
|
|
* is worse than the notification that was here before.
|
|
*/
|
|
const FACTS_KEY = `${BASE}/ihasmail-worker-facts`;
|
|
|
|
async function readFacts() {
|
|
try {
|
|
const hit = await (await caches.open(VERSION)).match(FACTS_KEY);
|
|
return hit ? await hit.json() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* A JMAP call, made as the reader.
|
|
*
|
|
* This worker was written believing it could not do this -- that acting on
|
|
* mail needed a session it had no way to hold. It does not: ihasmail's session
|
|
* is an httpOnly cookie against its own origin, and the only other thing the
|
|
* API asks for is a fixed `x-requested-with` header that is not a secret and
|
|
* is not held anywhere. A same-origin fetch from here carries the cookie like
|
|
* any other, so `Email/set` from a notification is an ordinary request.
|
|
*
|
|
* What is genuinely not available is anything the *tab* holds in memory, and
|
|
* the answer is that the API asks for none of it.
|
|
*
|
|
* The session can still be gone -- expired, signed out, or a cookie that did
|
|
* not survive the browser closing -- which arrives as a 401 and is reported
|
|
* rather than swallowed. A tap that silently does nothing is the failure worth
|
|
* avoiding here: the reader has already put the phone down.
|
|
*/
|
|
async function jmap(methodCalls) {
|
|
const res = await fetch(`${BASE}/api/jmap`, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
|
|
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const body = await res.json();
|
|
// A JMAP method can fail inside a 200. Treat that as a failure too, rather
|
|
// than reporting success because the transport was fine.
|
|
const first = body?.methodResponses?.[0];
|
|
if (!first || first[0] === "error") throw new Error(first?.[1]?.type || "error");
|
|
const notUpdated = first[1]?.notUpdated;
|
|
if (notUpdated && Object.keys(notUpdated).length) throw new Error("notUpdated");
|
|
return body;
|
|
}
|
|
|
|
function textOf(email, strings) {
|
|
const from = email?.from?.[0];
|
|
const who = from?.name || from?.email || strings.newMessage;
|
|
const what = email?.subject || strings.noSubject;
|
|
return { title: who, body: what, preview: email?.preview || "" };
|
|
}
|
|
|
|
/*
|
|
* Two, because that is what a phone shows. `Notification.maxActions` is 2 on
|
|
* Android Chrome, and anything past it is dropped silently -- so these are the
|
|
* two worth having rather than the two that happened to come first. Both are
|
|
* triage: they are what somebody does to a notification they have read the
|
|
* whole of on the lock screen and does not need to open.
|
|
*
|
|
* Reply is deliberately not among them. It cannot be done from here, so it
|
|
* would have to open the app -- and an action that opens the app is what
|
|
* tapping the notification already does.
|
|
*/
|
|
function actionsFor(facts) {
|
|
if (!facts) return [];
|
|
const actions = [];
|
|
if (facts.archiveId) actions.push({ action: "archive", title: facts.strings.archive });
|
|
actions.push({ action: "read", title: facts.strings.markRead });
|
|
return actions;
|
|
}
|
|
|
|
self.addEventListener("push", (event) => {
|
|
let data = null;
|
|
try {
|
|
data = event.data ? event.data.json() : null;
|
|
} catch {
|
|
/* not JSON: fall through to the generic notification below */
|
|
}
|
|
|
|
// The verification handshake. No credentials here, so hand it to a tab —
|
|
// an open one now, or the next one to start.
|
|
if (data && data["@type"] === "PushVerification") {
|
|
event.waitUntil((async () => {
|
|
const payload = { id: data.pushSubscriptionId, code: data.verificationCode };
|
|
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
|
if (clients.length) {
|
|
for (const c of clients) c.postMessage({ type: "push-verification", ...payload });
|
|
} else {
|
|
const cache = await caches.open(VERSION);
|
|
await cache.put(VERIFY_KEY, new Response(JSON.stringify(payload)));
|
|
}
|
|
})());
|
|
return;
|
|
}
|
|
|
|
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
|
event.waitUntil((async () => {
|
|
/*
|
|
* Someone reading the app already knows. A focused, visible window of this
|
|
* app gets its new mail from its own event stream, so a notification on
|
|
* top of it is a second telling of the same thing (#375). Chrome does not
|
|
* require one while the site is in the foreground.
|
|
*/
|
|
const windows = await self.clients.matchAll({ type: "window" });
|
|
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
|
|
const facts = await readFacts();
|
|
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
|
/*
|
|
* Mark the app icon, without claiming a number.
|
|
*
|
|
* `setAppBadge()` with no count shows a dot rather than a figure, which is
|
|
* the only honest thing to show from here: this worker has no session, so
|
|
* it cannot ask how many messages are unread, and a push carries the new
|
|
* mail rather than a total. Counting the payload would badge "2" over an
|
|
* inbox holding forty. The next time a tab opens, `setUnreadBadge` writes
|
|
* the real count over the dot.
|
|
*/
|
|
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
|
|
|
if (!emails.length) {
|
|
// A delivery from a server that sends StateChange rather than EmailPush
|
|
// -- the subscription asks for `EmailDelivery` only, so it is new mail --
|
|
// or a payload too large to carry the message. Say something true
|
|
// rather than inventing a sender.
|
|
await self.registration.showNotification(strings.newMail, {
|
|
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
|
});
|
|
return;
|
|
}
|
|
// One notification per message, collapsing repeats of the same message by
|
|
// tag so a re-push does not stack.
|
|
for (const email of emails.slice(0, 5)) {
|
|
const { title, body, preview } = textOf(email, strings);
|
|
await self.registration.showNotification(title, {
|
|
body: preview ? `${body}\n${preview}` : body,
|
|
icon: `${BASE}/img/icon-192.png`,
|
|
badge: `${BASE}/img/favicon-64.png`,
|
|
tag: `ihasmail-${email.id || body}`,
|
|
// Only where there is a message to act on: a payload without an id can
|
|
// be shown but not archived, and a button that cannot work should not
|
|
// be drawn.
|
|
actions: email.id ? actionsFor(facts) : [],
|
|
data: {
|
|
// The route names a conversation, and `m` the message in it.
|
|
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
|
|
id: email.id || null,
|
|
title,
|
|
accountId: facts?.accountId ?? null,
|
|
archiveId: facts?.archiveId ?? null,
|
|
failed: strings.failed ?? null,
|
|
},
|
|
});
|
|
}
|
|
})());
|
|
});
|
|
|
|
/*
|
|
* Do what the button said, without opening anything.
|
|
*
|
|
* The whole point of an action is that the phone goes back in the pocket, so
|
|
* this must not fall back to opening the app when the call fails -- that is
|
|
* the same interruption the action existed to avoid. It re-notifies instead,
|
|
* saying it did not happen, and leaves opening ihasmail to the reader.
|
|
*
|
|
* Archiving replaces the mailbox set rather than adding to it, which is what
|
|
* archiving is: the message leaves the inbox. Marking read is a keyword and
|
|
* touches nothing else.
|
|
*/
|
|
async function runAction(action, data) {
|
|
const { id, accountId, archiveId } = data;
|
|
if (!id || !accountId) return;
|
|
const patch = action === "archive"
|
|
? { mailboxIds: { [archiveId]: true } }
|
|
: { "keywords/$seen": true };
|
|
try {
|
|
if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
|
|
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]);
|
|
} catch {
|
|
await self.registration.showNotification(data.title || "ihasmail", {
|
|
body: data.failed || "Could not do that — open ihasmail and try again",
|
|
icon: `${BASE}/img/icon-192.png`,
|
|
badge: `${BASE}/img/favicon-64.png`,
|
|
tag: `ihasmail-failed-${id}`,
|
|
data: { url: data.url },
|
|
});
|
|
}
|
|
}
|
|
|
|
self.addEventListener("notificationclick", (event) => {
|
|
event.notification.close();
|
|
const data = event.notification.data || {};
|
|
if (event.action === "archive" || event.action === "read") {
|
|
event.waitUntil(runAction(event.action, data));
|
|
return;
|
|
}
|
|
const url = data.url || `${BASE}/mail`;
|
|
event.waitUntil((async () => {
|
|
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
|
// Reuse a tab if one is open rather than piling up windows. Same origin is
|
|
// not enough under a prefix: `includeUncontrolled` widens the match to the
|
|
// whole origin, so on a host that also serves something else this would
|
|
// navigate a stranger's tab to our inbox.
|
|
for (const c of clients) {
|
|
const at = new URL(c.url);
|
|
if (at.origin === self.location.origin && (at.pathname === BASE || at.pathname.startsWith(`${BASE}/`))) {
|
|
await c.focus();
|
|
if ("navigate" in c) await c.navigate(url).catch(() => {});
|
|
return;
|
|
}
|
|
}
|
|
await self.clients.openWindow(url);
|
|
})());
|
|
});
|