Merge pull request #68 from LINUXexpert-org/web-push
Notifications that arrive when ihasmail is closed
This commit is contained in:
@@ -76,6 +76,7 @@ seconds of downtime with nothing lost.
|
|||||||
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
|
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
|
||||||
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, **scheduled send** (quick picks or an exact date and time; the message waits in the server's queue, so it goes out whether or not ihasmail is open), autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
|
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, **scheduled send** (quick picks or an exact date and time; the message waits in the server's queue, so it goes out whether or not ihasmail is open), autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
|
||||||
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
|
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
|
||||||
|
- **Notifications that arrive with ihasmail closed** — Web Push signed with VAPID ([RFC 9749](https://datatracker.ietf.org/doc/rfc9749/)), which Stalwart 0.16 supports and without which Chromium and Safari cannot receive push at all. Stalwart pushes straight to the browser's own push service: no relay, no extra service to run, and ihasmail's server is not in the delivery path. With [`emailpush`](https://datatracker.ietf.org/doc/draft-ietf-jmap-emailpush/) the payload carries the sender and subject, so the notification is useful without a round-trip — and the filter lives on the server, so spam never leaves it. The subscription is torn down on sign-out, because it belongs to the account rather than the session and would otherwise keep notifying a shared machine for a mailbox nobody is signed into
|
||||||
- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts
|
- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts
|
||||||
|
|
||||||
**Calendar** (JMAP Calendars / JSCalendar)
|
**Calendar** (JMAP Calendars / JSCalendar)
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const nextState = () => String(state.n++);
|
|||||||
* list no user has. The role is what the client branches on; the name is only
|
* list no user has. The role is what the client branches on; the name is only
|
||||||
* ever displayed, which is exactly why it has to look right.
|
* ever displayed, which is exactly why it has to look right.
|
||||||
*/
|
*/
|
||||||
|
/** Push subscriptions, as a fresh account has none. */
|
||||||
|
const pushSubscriptions: Obj[] = [];
|
||||||
|
|
||||||
const mailboxes: Obj[] = [
|
const mailboxes: Obj[] = [
|
||||||
mb("inbox", "Inbox", "inbox"),
|
mb("inbox", "Inbox", "inbox"),
|
||||||
mb("drafts", "Drafts", "drafts"),
|
mb("drafts", "Drafts", "drafts"),
|
||||||
@@ -476,6 +479,73 @@ const handlers: Record<string, Handler> = {
|
|||||||
state.n++;
|
state.n++;
|
||||||
return setResp({ updated: { singleton: null } });
|
return setResp({ updated: { singleton: null } });
|
||||||
},
|
},
|
||||||
|
/*
|
||||||
|
* Push subscriptions. The JMAP half can be modelled; delivery cannot -- that
|
||||||
|
* runs through the browser vendor's real push service, so nothing local will
|
||||||
|
* ever make a notification appear.
|
||||||
|
*
|
||||||
|
* What is worth reproducing is the handshake, because it is the part that
|
||||||
|
* fails quietly: a subscription is created unverified and stays silent until
|
||||||
|
* the client echoes back a code the server pushed. A mock that marked one
|
||||||
|
* verified on creation would let a client ship without ever implementing
|
||||||
|
* that, and the symptom in production is "registered, and no notifications".
|
||||||
|
*/
|
||||||
|
"PushSubscription/get": (a) => {
|
||||||
|
const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string);
|
||||||
|
const list = pushSubscriptions.filter((s) => ids.includes(s.id as string));
|
||||||
|
// `keys` is write-only in JMAP: the server never hands it back.
|
||||||
|
return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) };
|
||||||
|
},
|
||||||
|
"PushSubscription/set": (a) => {
|
||||||
|
const created: Obj = {};
|
||||||
|
const notCreated: Obj = {};
|
||||||
|
const updated: Obj = {};
|
||||||
|
const notUpdated: Obj = {};
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
|
const o = obj as Obj;
|
||||||
|
const keys = (o.keys ?? {}) as Obj;
|
||||||
|
// Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push
|
||||||
|
// API produces; padding it would be the client inventing a shape.
|
||||||
|
for (const k of ["p256dh", "auth"]) {
|
||||||
|
const v = String(keys[k] ?? "");
|
||||||
|
if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; }
|
||||||
|
if (v.includes("=") || v.includes("+") || v.includes("/")) {
|
||||||
|
notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notCreated[cid]) continue;
|
||||||
|
if (!String(o.url ?? "").startsWith("https://")) {
|
||||||
|
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// One per device: re-subscribing replaces rather than accumulates.
|
||||||
|
const deviceId = String(o.deviceClientId ?? "");
|
||||||
|
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
|
||||||
|
if (clash >= 0) pushSubscriptions.splice(clash, 1);
|
||||||
|
const id = `ps${randomUUID().slice(0, 6)}`;
|
||||||
|
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires: null, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
|
||||||
|
created[cid] = { id, expires: null };
|
||||||
|
state.n++;
|
||||||
|
}
|
||||||
|
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
||||||
|
const s = pushSubscriptions.find((x) => x.id === id);
|
||||||
|
if (!s) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||||
|
const code = (patch as Obj).verificationCode;
|
||||||
|
if (code !== undefined) {
|
||||||
|
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
|
||||||
|
s.verified = true;
|
||||||
|
}
|
||||||
|
updated[id] = null;
|
||||||
|
state.n++;
|
||||||
|
}
|
||||||
|
for (const id of (a.destroy as string[]) ?? []) {
|
||||||
|
const i = pushSubscriptions.findIndex((x) => x.id === id);
|
||||||
|
if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; }
|
||||||
|
}
|
||||||
|
return setResp({ created, notCreated, updated, notUpdated, destroyed });
|
||||||
|
},
|
||||||
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
|
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
|
||||||
"x:AppPassword/set": (a) => {
|
"x:AppPassword/set": (a) => {
|
||||||
const created: Obj = {};
|
const created: Obj = {};
|
||||||
@@ -667,7 +737,9 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = () => ({
|
const session = () => ({
|
||||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" },
|
||||||
|
"urn:ietf:params:jmap:emailpush": {},
|
||||||
|
"urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
||||||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||||
username: USER,
|
username: USER,
|
||||||
|
|||||||
+96
-2
@@ -1,5 +1,7 @@
|
|||||||
/* ihasmail service worker: app-shell caching for installability & fast loads.
|
/* ihasmail service worker.
|
||||||
API requests are never cached. */
|
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";
|
const VERSION = "ihasmail-v2";
|
||||||
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
|
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
|
||||||
|
|
||||||
@@ -39,3 +41,95 @@ self.addEventListener("fetch", (event) => {
|
|||||||
}
|
}
|
||||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
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. 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 matters, because when this fires
|
||||||
|
* there may be no session to make one with.
|
||||||
|
*
|
||||||
|
* A JMAP subscription also delivers a PushVerification first, and stays silent
|
||||||
|
* until the client echoes its code back. That cannot be done from here (no
|
||||||
|
* credentials), so it is stashed for a tab to collect and confirm.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VERIFY_KEY = "ihasmail-push-verification";
|
||||||
|
|
||||||
|
function textOf(email) {
|
||||||
|
const from = email?.from?.[0];
|
||||||
|
const who = from?.name || from?.email || "New message";
|
||||||
|
const what = email?.subject || "(no subject)";
|
||||||
|
return { title: who, body: what, preview: email?.preview || "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
if (!emails.length) {
|
||||||
|
// A StateChange, or a payload too large to carry the message. Say
|
||||||
|
// something true rather than inventing a sender.
|
||||||
|
await self.registration.showNotification("New mail", {
|
||||||
|
icon: "/img/icon-192.png", badge: "/img/favicon-64.png", tag: "ihasmail-mail", data: { url: "/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);
|
||||||
|
await self.registration.showNotification(title, {
|
||||||
|
body: preview ? `${body}\n${preview}` : body,
|
||||||
|
icon: "/img/icon-192.png",
|
||||||
|
badge: "/img/favicon-64.png",
|
||||||
|
tag: `ihasmail-${email.id || body}`,
|
||||||
|
data: { url: email.id ? `/mail/inbox/${email.id}` : "/mail" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data?.url || "/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.
|
||||||
|
for (const c of clients) {
|
||||||
|
if (new URL(c.url).origin === self.location.origin) {
|
||||||
|
await c.focus();
|
||||||
|
if ("navigate" in c) await c.navigate(url).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.clients.openWindow(url);
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
|
|||||||
import { setUnreadBadge } from "@/lib/notify";
|
import { setUnreadBadge } from "@/lib/notify";
|
||||||
import { useSettings, syncedPart } from "@/store/settings";
|
import { useSettings, syncedPart } from "@/store/settings";
|
||||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
|
import { listenForVerification } from "@/lib/webpushEnable";
|
||||||
|
|
||||||
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||||
@@ -87,6 +88,9 @@ function AuthedApp() {
|
|||||||
void useFiles.getState().init();
|
void useFiles.getState().init();
|
||||||
void useSieve.getState().init();
|
void useSieve.getState().init();
|
||||||
push.start();
|
push.start();
|
||||||
|
// A push subscription stays silent until its verification code is echoed
|
||||||
|
// back, and the code may have arrived while no tab was open.
|
||||||
|
listenForVerification();
|
||||||
const pending = new Map<string, Set<string>>();
|
const pending = new Map<string, Set<string>>();
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
const unsub = push.subscribe((acct, type) => {
|
const unsub = push.subscribe((acct, type) => {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { client } from "@/jmap/client";
|
||||||
|
import {
|
||||||
|
applicationServerKey,
|
||||||
|
decodeApplicationServerKey,
|
||||||
|
encodeKey,
|
||||||
|
subscriptionPayload,
|
||||||
|
supportsEmailPush,
|
||||||
|
webPushAvailable,
|
||||||
|
} from "@/lib/webpush";
|
||||||
|
import type { JmapSession } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key encoding is where this breaks silently. `subscribe()` fails with an
|
||||||
|
* opaque error on a mis-decoded VAPID key, and Stalwart 0.16 had to be fixed to
|
||||||
|
* accept the *unpadded* base64url the W3C Push API produces — so re-padding on
|
||||||
|
* the way out would be sending a shape the server has not been tested against.
|
||||||
|
*
|
||||||
|
* The real key from the live 0.16.19 is used below rather than a made-up one:
|
||||||
|
* its length is what exercises the padding arithmetic.
|
||||||
|
*/
|
||||||
|
const LIVE_KEY = "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY";
|
||||||
|
|
||||||
|
function session(caps: Record<string, unknown>): JmapSession {
|
||||||
|
return { capabilities: caps, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
client.session = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the VAPID key", () => {
|
||||||
|
it("is read from the capability the server publishes", () => {
|
||||||
|
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
||||||
|
expect(applicationServerKey()).toBe(LIVE_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null when the server does not do Web Push, rather than an empty string", () => {
|
||||||
|
client.session = session({ "urn:ietf:params:jmap:core": {} });
|
||||||
|
expect(applicationServerKey()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes to the 65 bytes of an uncompressed P-256 point", () => {
|
||||||
|
const buf = decodeApplicationServerKey(LIVE_KEY);
|
||||||
|
expect(buf.byteLength).toBe(65);
|
||||||
|
// 0x04 marks an uncompressed EC point; the Push API rejects anything else.
|
||||||
|
expect(new Uint8Array(buf)[0]).toBe(0x04);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles base64url without padding, which is how it arrives", () => {
|
||||||
|
expect(LIVE_KEY).not.toContain("=");
|
||||||
|
expect(LIVE_KEY).toMatch(/[-_]/);
|
||||||
|
expect(() => decodeApplicationServerKey(LIVE_KEY)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an ArrayBuffer, which is what subscribe() accepts", () => {
|
||||||
|
expect(decodeApplicationServerKey(LIVE_KEY)).toBeInstanceOf(ArrayBuffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("encoding keys for the server", () => {
|
||||||
|
it("produces unpadded base64url, the form Stalwart was fixed to accept", () => {
|
||||||
|
// 5 bytes: a length that would be padded with "===" in standard base64.
|
||||||
|
const buf = new Uint8Array([1, 2, 3, 4, 5]).buffer;
|
||||||
|
const out = encodeKey(buf);
|
||||||
|
expect(out).not.toContain("=");
|
||||||
|
expect(out).not.toContain("+");
|
||||||
|
expect(out).not.toContain("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through the decoder", () => {
|
||||||
|
const bytes = new Uint8Array([0, 255, 128, 64, 32, 16]);
|
||||||
|
expect(new Uint8Array(decodeApplicationServerKey(encodeKey(bytes.buffer)))).toEqual(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an empty string rather than throwing on a missing key", () => {
|
||||||
|
expect(encodeKey(null)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what gets registered", () => {
|
||||||
|
const fakeSub = {
|
||||||
|
endpoint: "https://push.example/abc",
|
||||||
|
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
|
||||||
|
getKey: () => null,
|
||||||
|
} as unknown as PushSubscription;
|
||||||
|
|
||||||
|
it("asks for the message itself when the server supports emailpush", () => {
|
||||||
|
client.session = session({
|
||||||
|
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
|
||||||
|
"urn:ietf:params:jmap:emailpush": {},
|
||||||
|
});
|
||||||
|
const body = subscriptionPayload(fakeSub, "a1") as Record<string, any>;
|
||||||
|
expect(body.url).toBe("https://push.example/abc");
|
||||||
|
expect(body.keys).toEqual({ p256dh: "cGRoLWtleQ", auth: "YXV0aA" });
|
||||||
|
expect(body.emailPush.a1.properties).toContain("subject");
|
||||||
|
expect(body.emailPush.a1.properties).toContain("from");
|
||||||
|
// Order is priority: the server drops from the end when the payload is
|
||||||
|
// too large, so the sender must outrank the preview.
|
||||||
|
const props: string[] = body.emailPush.a1.properties;
|
||||||
|
expect(props.indexOf("from")).toBeLessThan(props.indexOf("preview"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits emailPush entirely when the server does not support it", () => {
|
||||||
|
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
||||||
|
expect(supportsEmailPush()).toBe(false);
|
||||||
|
expect(subscriptionPayload(fakeSub, "a1")).not.toHaveProperty("emailPush");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits emailPush when there is no account to scope it to", () => {
|
||||||
|
client.session = session({
|
||||||
|
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
|
||||||
|
"urn:ietf:params:jmap:emailpush": {},
|
||||||
|
});
|
||||||
|
expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribes to Email changes only, since EventSource covers an open tab", () => {
|
||||||
|
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
||||||
|
expect((subscriptionPayload(fakeSub, "a1") as Record<string, unknown>).types).toEqual(["Email"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("availability", () => {
|
||||||
|
it("is false without a push key, however capable the browser", () => {
|
||||||
|
client.session = session({ "urn:ietf:params:jmap:core": {} });
|
||||||
|
expect(webPushAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Web Push: notifications that arrive when ihasmail is not open.
|
||||||
|
*
|
||||||
|
* The existing EventSource channel only lives as long as a tab does, so
|
||||||
|
* "desktop notifications" have really meant "while you are looking". Stalwart
|
||||||
|
* 0.16 signs Web Push with VAPID (RFC 9749) and can carry the message itself in
|
||||||
|
* the payload (draft-ietf-jmap-emailpush), so the browser's own push service
|
||||||
|
* delivers a useful notification with ihasmail closed.
|
||||||
|
*
|
||||||
|
* Nothing in this path touches ihasmail's server. Stalwart talks to the push
|
||||||
|
* service directly; the only thing proxied is the JMAP call that registers the
|
||||||
|
* subscription. That is deliberate — it is why this needs no relay, no extra
|
||||||
|
* service to run, and no third party beyond the browser vendor's push endpoint
|
||||||
|
* that Web Push requires of everyone.
|
||||||
|
*
|
||||||
|
* Verified against the live 0.16.19 before this was written: the server
|
||||||
|
* publishes a real `applicationServerKey`, and `PushSubscription/get` answers a
|
||||||
|
* normal user rather than refusing them.
|
||||||
|
*/
|
||||||
|
import { CAP, client } from "@/jmap/client";
|
||||||
|
import type { GetResponse, Id, SetResponse } from "@/jmap/types";
|
||||||
|
|
||||||
|
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
||||||
|
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
|
||||||
|
|
||||||
|
/** Which Email properties to put in the payload, best first. */
|
||||||
|
const PAYLOAD_PROPS = ["from", "subject", "preview", "receivedAt"];
|
||||||
|
|
||||||
|
export interface JmapPushSubscription {
|
||||||
|
id: Id;
|
||||||
|
deviceClientId: string;
|
||||||
|
url: string;
|
||||||
|
expires: string | null;
|
||||||
|
verificationCode?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The VAPID key this server signs with, or null if it does not do Web Push. */
|
||||||
|
export function applicationServerKey(): string | null {
|
||||||
|
const cap = client.session?.capabilities?.[VAPID_CAP] as { applicationServerKey?: string } | undefined;
|
||||||
|
return typeof cap?.applicationServerKey === "string" ? cap.applicationServerKey : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the payload can carry the message, rather than only "something changed". */
|
||||||
|
export function supportsEmailPush(): boolean {
|
||||||
|
return Boolean(client.session?.capabilities && EMAILPUSH_CAP in client.session.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this browser and this server can do Web Push at all. */
|
||||||
|
export function webPushAvailable(): boolean {
|
||||||
|
return (
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
"serviceWorker" in navigator &&
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
"PushManager" in window &&
|
||||||
|
applicationServerKey() !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The VAPID key as the Push API wants it.
|
||||||
|
*
|
||||||
|
* It arrives base64url and unpadded; `atob` needs standard base64 with padding.
|
||||||
|
* Getting this wrong fails at subscribe() with an opaque error, which is the
|
||||||
|
* sort of thing worth doing in one place with a name.
|
||||||
|
*/
|
||||||
|
export function decodeApplicationServerKey(key: string): ArrayBuffer {
|
||||||
|
const padded = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4);
|
||||||
|
const raw = atob(padded);
|
||||||
|
// An ArrayBuffer rather than a Uint8Array: TypeScript 5.7 types the latter
|
||||||
|
// over ArrayBufferLike, which no longer satisfies BufferSource, and
|
||||||
|
// subscribe() wants a BufferSource.
|
||||||
|
const buffer = new ArrayBuffer(raw.length);
|
||||||
|
const out = new Uint8Array(buffer);
|
||||||
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64url, unpadded — the form the W3C Push API produces for its keys.
|
||||||
|
*
|
||||||
|
* Stalwart 0.16 had to be fixed to accept unpadded keys, so this deliberately
|
||||||
|
* does not pad: sending what the browser gave us is the case the server now
|
||||||
|
* handles, and re-padding would be inventing a shape nobody tested.
|
||||||
|
*/
|
||||||
|
export function encodeKey(buffer: ArrayBuffer | null): string {
|
||||||
|
if (!buffer) return "";
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable id for this browser, so a re-subscribe replaces rather than piles up. */
|
||||||
|
export function deviceClientId(): string {
|
||||||
|
const KEY = "ihasmail:pushDeviceId";
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem(KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const made = `ihasmail-${crypto.randomUUID()}`;
|
||||||
|
localStorage.setItem(KEY, made);
|
||||||
|
return made;
|
||||||
|
} catch {
|
||||||
|
// Private mode: a per-session id still works, it just will not be reused.
|
||||||
|
return `ihasmail-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What to send Stalwart for a browser subscription. */
|
||||||
|
export function subscriptionPayload(sub: PushSubscription, accountId: Id | null): Record<string, unknown> {
|
||||||
|
const json = sub.toJSON();
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
deviceClientId: deviceClientId(),
|
||||||
|
url: sub.endpoint,
|
||||||
|
keys: { p256dh: json.keys?.p256dh ?? encodeKey(sub.getKey("p256dh")), auth: json.keys?.auth ?? encodeKey(sub.getKey("auth")) },
|
||||||
|
// StateChange notifications are not wanted: the app already has EventSource
|
||||||
|
// while it is open, and this channel exists for when it is not.
|
||||||
|
types: ["Email"],
|
||||||
|
};
|
||||||
|
if (accountId && supportsEmailPush()) {
|
||||||
|
body.emailPush = {
|
||||||
|
[accountId]: {
|
||||||
|
// Only mail that actually lands in the inbox. Filtering here rather
|
||||||
|
// than in the service worker means spam never leaves the server.
|
||||||
|
filter: { inMailbox: null, notKeyword: "$seen" },
|
||||||
|
properties: PAYLOAD_PROPS,
|
||||||
|
urgency: "normal",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSubscriptions(): Promise<JmapPushSubscription[]> {
|
||||||
|
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
|
||||||
|
return res.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
|
||||||
|
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
||||||
|
"PushSubscription/set",
|
||||||
|
{ create: { s: body } },
|
||||||
|
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
|
||||||
|
);
|
||||||
|
if (res.notCreated?.s) throw new Error(String(res.notCreated.s.description ?? res.notCreated.s.type));
|
||||||
|
return (res.created?.s as { id?: Id } | undefined)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand back the code the server pushed.
|
||||||
|
*
|
||||||
|
* A JMAP push subscription delivers nothing until this round-trip completes —
|
||||||
|
* the server sends a code over the channel to prove it reaches this client, and
|
||||||
|
* the client echoes it. A subscription left unverified looks registered and is
|
||||||
|
* silent, which is the confusing failure worth being explicit about.
|
||||||
|
*/
|
||||||
|
export async function verifySubscription(id: Id, verificationCode: string): Promise<void> {
|
||||||
|
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
||||||
|
"PushSubscription/set",
|
||||||
|
{ update: { [id]: { verificationCode } } },
|
||||||
|
[CAP.core, VAPID_CAP],
|
||||||
|
);
|
||||||
|
const err = res.notUpdated?.[id];
|
||||||
|
if (err) throw new Error(String(err.description ?? err.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySubscription(id: Id): Promise<void> {
|
||||||
|
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove every subscription this browser registered. Used when signing out. */
|
||||||
|
export async function unsubscribeThisDevice(): Promise<void> {
|
||||||
|
const mine = deviceClientId();
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker?.getRegistration();
|
||||||
|
const sub = await reg?.pushManager.getSubscription();
|
||||||
|
await sub?.unsubscribe();
|
||||||
|
} catch {
|
||||||
|
/* the browser end is gone or was never there; still clear the server end */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const subs = await listSubscriptions();
|
||||||
|
for (const s of subs) if (s.deviceClientId === mine) await destroySubscription(s.id);
|
||||||
|
} catch {
|
||||||
|
/* signing out must not fail over this */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Turning Web Push on and off, and completing the handshake it needs.
|
||||||
|
*
|
||||||
|
* Kept apart from `webpush.ts` so that module stays pure JMAP and stays
|
||||||
|
* testable: everything here touches the browser's service worker and
|
||||||
|
* permission prompt, none of which exists under a test runner.
|
||||||
|
*/
|
||||||
|
import { CAP } from "@/jmap/client";
|
||||||
|
import { useSession } from "@/store/session";
|
||||||
|
import {
|
||||||
|
applicationServerKey,
|
||||||
|
createSubscription,
|
||||||
|
decodeApplicationServerKey,
|
||||||
|
listSubscriptions,
|
||||||
|
subscriptionPayload,
|
||||||
|
unsubscribeThisDevice,
|
||||||
|
verifySubscription,
|
||||||
|
webPushAvailable,
|
||||||
|
} from "@/lib/webpush";
|
||||||
|
|
||||||
|
let listening = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for the verification code the server pushes.
|
||||||
|
*
|
||||||
|
* The service worker cannot answer it — a JMAP call needs the session cookie
|
||||||
|
* and this is a background context — so it forwards the code here, or leaves it
|
||||||
|
* in the cache when no tab was open to forward it to.
|
||||||
|
*/
|
||||||
|
export function listenForVerification(): void {
|
||||||
|
if (listening || typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
||||||
|
listening = true;
|
||||||
|
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
|
||||||
|
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
|
||||||
|
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {});
|
||||||
|
});
|
||||||
|
void collectStoredVerification();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick up a code that arrived while no tab was open. */
|
||||||
|
async function collectStoredVerification(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open("ihasmail-v2");
|
||||||
|
const hit = await cache.match("ihasmail-push-verification");
|
||||||
|
if (!hit) return;
|
||||||
|
const { id, code } = (await hit.json()) as { id?: string; code?: string };
|
||||||
|
await cache.delete("ihasmail-push-verification");
|
||||||
|
if (id && code) await verifySubscription(id, code);
|
||||||
|
} catch {
|
||||||
|
/* nothing waiting, or no cache: not a failure */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe this browser. Safe to call again — the deviceClientId makes a
|
||||||
|
* repeat replace rather than accumulate.
|
||||||
|
*
|
||||||
|
* Returns why it could not, rather than throwing, because every reason is
|
||||||
|
* something to tell the user plainly: an old server, a browser without push, a
|
||||||
|
* permission they declined.
|
||||||
|
*/
|
||||||
|
export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||||
|
if (!webPushAvailable()) {
|
||||||
|
return { ok: false, reason: "This browser or mail server does not support background notifications." };
|
||||||
|
}
|
||||||
|
if (Notification.permission === "denied") {
|
||||||
|
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." };
|
||||||
|
}
|
||||||
|
const key = applicationServerKey();
|
||||||
|
if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const existing = await reg.pushManager.getSubscription();
|
||||||
|
const sub = existing ?? (await reg.pushManager.subscribe({
|
||||||
|
// Web Push requires it, and Chrome refuses a subscription without it.
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: decodeApplicationServerKey(key),
|
||||||
|
}));
|
||||||
|
const accountId = useSession.getState().accountFor(CAP.mail);
|
||||||
|
await createSubscription(subscriptionPayload(sub, accountId));
|
||||||
|
listenForVerification();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove this browser's subscription, at the browser and at the server. */
|
||||||
|
export async function disableWebPush(): Promise<void> {
|
||||||
|
await unsubscribeThisDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this browser currently has a verified subscription registered. */
|
||||||
|
export async function webPushActive(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker?.getRegistration();
|
||||||
|
if (!(await reg?.pushManager.getSubscription())) return false;
|
||||||
|
return (await listSubscriptions()).length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { Id, JmapSession } from "@/jmap/types";
|
|||||||
import { push, type PushState } from "@/jmap/push";
|
import { push, type PushState } from "@/jmap/push";
|
||||||
import { setServerLocale } from "@/lib/datetime";
|
import { setServerLocale } from "@/lib/datetime";
|
||||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||||
|
import { unsubscribeThisDevice } from "@/lib/webpush";
|
||||||
|
|
||||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||||
|
|
||||||
@@ -62,6 +63,14 @@ export const useSession = create<SessionState>((set, get) => ({
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
// A push subscription lives on the account, not the session, so signing out
|
||||||
|
// without removing it leaves this browser notifying for a mailbox nobody is
|
||||||
|
// signed into. On a shared machine that is somebody else's mail.
|
||||||
|
try {
|
||||||
|
await unsubscribeThisDevice();
|
||||||
|
} catch {
|
||||||
|
/* never block signing out over this */
|
||||||
|
}
|
||||||
stopSettingsSync();
|
stopSettingsSync();
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||||
|
|||||||
@@ -3,12 +3,21 @@ import { useSettings } from "@/store/settings";
|
|||||||
import { Switch } from "@/ui/misc";
|
import { Switch } from "@/ui/misc";
|
||||||
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
|
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
|
import { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnable";
|
||||||
|
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
|
||||||
|
import { toast } from "@/ui/toast";
|
||||||
|
|
||||||
export function NotificationsSettings() {
|
export function NotificationsSettings() {
|
||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
const update = useSettings((st) => st.update);
|
const update = useSettings((st) => st.update);
|
||||||
const pushConnected = useSession((st) => st.pushConnected);
|
const pushConnected = useSession((st) => st.pushConnected);
|
||||||
const [perm, setPerm] = useState<NotificationPermission | "unsupported">("Notification" in window ? Notification.permission : "unsupported");
|
const [perm, setPerm] = useState<NotificationPermission | "unsupported">("Notification" in window ? Notification.permission : "unsupported");
|
||||||
|
const [background, setBackground] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const canBackground = webPushAvailable();
|
||||||
|
useEffect(() => {
|
||||||
|
void webPushActive().then(setBackground);
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ("Notification" in window) setPerm(Notification.permission);
|
if ("Notification" in window) setPerm(Notification.permission);
|
||||||
}, [s.desktopNotifications]);
|
}, [s.desktopNotifications]);
|
||||||
@@ -26,10 +35,46 @@ export function NotificationsSettings() {
|
|||||||
}
|
}
|
||||||
update({ desktopNotifications: v });
|
update({ desktopNotifications: v });
|
||||||
}}
|
}}
|
||||||
label="Desktop notifications for new mail"
|
label="Desktop notifications while ihasmail is open"
|
||||||
hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."}
|
hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."}
|
||||||
disabled={perm === "denied" || perm === "unsupported"}
|
disabled={perm === "denied" || perm === "unsupported"}
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
The distinction worth drawing for the user: the switch above needs a tab
|
||||||
|
open, this one does not. Everything before this shipped only the first
|
||||||
|
kind, while calling it "desktop notifications".
|
||||||
|
*/}
|
||||||
|
<Switch
|
||||||
|
checked={background}
|
||||||
|
disabled={!canBackground || busy || perm === "denied"}
|
||||||
|
onChange={async (v) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (v) {
|
||||||
|
const p = await requestNotificationPermission();
|
||||||
|
setPerm(p);
|
||||||
|
if (p !== "granted") return;
|
||||||
|
const res = await enableWebPush();
|
||||||
|
if (!res.ok) { toast.error(res.reason); return; }
|
||||||
|
setBackground(true);
|
||||||
|
toast.success("Background notifications are on");
|
||||||
|
} else {
|
||||||
|
await disableWebPush();
|
||||||
|
setBackground(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label="Notify me even when ihasmail is closed"
|
||||||
|
hint={
|
||||||
|
!canBackground
|
||||||
|
? "Needs a browser with the Push API and a mail server that publishes a push key."
|
||||||
|
: supportsEmailPush()
|
||||||
|
? "Your mail server delivers these directly to your browser, so they arrive with no tab open. The sender and subject travel in the notification."
|
||||||
|
: "Your mail server can wake this browser, but will not include the sender or subject."
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
|
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
|
||||||
<div className="row mt-16">
|
<div className="row mt-16">
|
||||||
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>
|
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user