diff --git a/FEATURES.md b/FEATURES.md index cd1acc3..27a5876 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1119,9 +1119,14 @@ needed nothing in either half. when the open page happened to be the root. - **The subscription is renewed on every app start**, because a JMAP push subscription expires — seven days is the ceiling — and re-registering before - it lapses is the client's job. Renewal can only happen with a page open: - registering is a JMAP call and the service worker has no session to make one - with. So the guarantee is that background notifications keep working as long + it lapses is the client's job. Renewal happens with a page open, and the + reason is *when* the service worker runs rather than what it is allowed to + do: it only wakes for an event, and the event that would wake it is a push + that stops arriving the moment the subscription lapses. A renewal that can + only run while renewal is still unnecessary is no schedule at all. (This + page previously said the worker had no session to register with. That was + wrong — see **Acting on a notification** below.) So the guarantee is that + background notifications keep working as long as ihasmail is opened now and again, and the two-day renewal window means once a week is enough. A browser that dropped or rotated its subscription on its own is re-subscribed at the same moment, rather than left with a switch @@ -1150,9 +1155,11 @@ needed nothing in either half. neither -- in `display: standalone` there is no tab strip and no favicon on screen, so a home-screen ihasmail showed nothing at all. Web Push marks the icon while the app is closed, with a dot rather than a figure: the service - worker has no session to ask how many messages are unread, and a push carries - the new mail rather than a total, so counting the payload would badge "2" over - an inbox holding forty. The next tab to open writes the real count over it. + worker is not told how many messages are unread — a push carries the new mail + rather than a total, so counting the payload would badge "2" over an inbox + holding forty. The next tab to open writes the real count over it. It could + now ask, which is a change since this was written; whether a badge is worth a + request on every push is a separate question and has not been answered yet. Unsupported browsers show nothing, as does iOS until notification permission has been granted, which is that platform's condition for a badge. - **In the share sheet** — share a photo, a link or a file from any other app @@ -1169,6 +1176,34 @@ needed nothing in either half. expires after ten minutes rather than opening a composer full of a forgotten photo the next time you look. Android and Chromium only; iOS does not implement share targets. +- **Acting on a notification.** Archive and Mark as read sit on the + notification itself, and both happen where you are — the phone stays in your + hand, or in your pocket. They are the two a phone shows: `maxActions` is two + on Android, and anything past it is dropped silently, so these are the two + worth having rather than the two that came first. Reply is deliberately not + among them, because it would have to open the app, and tapping the + notification already does that. + + This was described here as impossible, and it is worth saying why it was not. + ihasmail's session is an httpOnly cookie against its own origin, and the only + other thing the API asks for is a fixed header that is not a secret. A + same-origin request from the service worker carries the cookie like any + other, so `Email/set` from a notification is an ordinary call. What the + worker genuinely cannot reach is anything a *tab* holds in memory — and the + API asks for none of it. + + What it cannot reach is a catalogue. The worker is plain JavaScript outside + the bundle, with no i18n and no idea which mailbox is the archive, so the app + writes both down for it whenever the language, the account or the folder list + changes. Where there is no such note — between installing a new worker and + next opening ihasmail — the notification appears with no action buttons at + all rather than English ones over a guessed mailbox. + + A session can still be gone by the time a button is pressed: expired, signed + out, or a cookie that did not outlive the browser. That comes back as a + refusal, and the notification says so rather than disappearing as though it + had worked. It does not open the app to recover — being interrupted is the + thing the button existed to avoid. - **Share** — a message, or one attachment, handed to the operating system's share sheet instead of to the filesystem. On a phone a download is close to a dead end: the file lands in Downloads and whoever wanted to send it somewhere diff --git a/web/public/sw.js b/web/public/sw.js index 605cafe..6ca0962 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -130,14 +130,23 @@ self.addEventListener("fetch", (event) => { /* * 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. + * 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. That cannot be done from here (no - * credentials), so it is stashed for a tab to collect and confirm. + * 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. */ /* @@ -153,13 +162,90 @@ self.addEventListener("fetch", (event) => { */ const VERIFY_KEY = `${BASE}/ihasmail-push-verification`; -function textOf(email) { +/* + * 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 || "New message"; - const what = email?.subject || "(no subject)"; + 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 { @@ -186,6 +272,8 @@ self.addEventListener("push", (event) => { const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : []; event.waitUntil((async () => { + 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. * @@ -201,7 +289,7 @@ self.addEventListener("push", (event) => { 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", { + 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; @@ -209,21 +297,69 @@ self.addEventListener("push", (event) => { // 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); + 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}`, - data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` }, + // 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: { + url: email.id ? `${BASE}/mail/inbox/${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 url = event.notification.data?.url || `${BASE}/mail`; + 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 diff --git a/web/src/App.tsx b/web/src/App.tsx index dd5f944..f484933 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,6 +17,7 @@ import { AppShell } from "@/views/AppShell"; import { MailView } from "@/views/mail/MailView"; import { ComposerDock } from "@/views/compose/ComposerDock"; import { setUnreadBadge } from "@/lib/notify"; +import { publishWorkerFacts } from "@/lib/swFacts"; import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings"; import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync"; import { loadSettingsPolicy } from "@/lib/settingsPolicy"; @@ -263,6 +264,21 @@ function AuthedApp() { }); }, [inboxUnread, appName]); + /* + * Leave the service worker its briefing. + * + * Written from here rather than once at startup because everything in it can + * change while the app is open -- the language from Settings, the archive + * folder from the mailbox list arriving -- and what is written is what the + * worker will still be reading a week from now, with no tab to correct it. + * See lib/swFacts.ts. + */ + const archiveId = useMail((s) => s.roleId("archive")); + const languageVersion = useLanguageVersion(); + useEffect(() => { + void publishWorkerFacts(accountId, archiveId); + }, [accountId, archiveId, languageVersion]); + // Request notification permission lazily when enabled const notif = useSettings((s) => s.settings.desktopNotifications); useEffect(() => { diff --git a/web/src/lib/__tests__/swFacts.test.ts b/web/src/lib/__tests__/swFacts.test.ts new file mode 100644 index 0000000..8010273 --- /dev/null +++ b/web/src/lib/__tests__/swFacts.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/swFacts"; +import { SW_CACHE_NAME } from "@/lib/swCache"; +import { setCatalog } from "@/lib/i18n"; +import { catalog as de } from "@/locales/de"; + +/** + * The briefing is the only thing standing between a notification action and a + * button labelled in a language the reader does not use — the worker is plain + * JavaScript outside the bundle and cannot reach a catalogue. + * + * It is also the only place the archive mailbox is named, and getting that + * wrong does not fail visibly: a message would be filed somewhere, just not + * where Archive means. + */ + +function fakeCaches() { + const store = new Map(); + const cache = { + put: vi.fn(async (key: string, res: Response) => void store.set(key, await res.text())), + match: vi.fn(async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined)), + delete: vi.fn(async () => true), + }; + // Only the worker's own cache: a briefing put anywhere else is one the + // worker will never read. + const other = { put: vi.fn(), match: vi.fn(), delete: vi.fn() }; + vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : other)) }); + return { store, cache }; +} + +const written = (store: Map) => JSON.parse(store.get(FACTS_KEY)!) as WorkerFacts; + +afterEach(() => { + vi.unstubAllGlobals(); + setCatalog("en", { strings: {}, plurals: {} }); +}); + +describe("the worker's briefing", () => { + it("names the account and the archive mailbox", async () => { + const { store } = fakeCaches(); + await publishWorkerFacts("a1", "mb-archive"); + const facts = written(store); + expect(facts.accountId).toBe("a1"); + expect(facts.archiveId).toBe("mb-archive"); + }); + + it("carries the worker's text in the language the tab is in", async () => { + // The worker has no catalogue. Everything it will say has to be said here + // first, or a German reader gets English buttons on their lock screen. + setCatalog("de", de); + const { store } = fakeCaches(); + await publishWorkerFacts("a1", "mb-archive"); + const facts = written(store); + expect(facts.strings.archive).toBe("Archivieren"); + expect(facts.strings.markRead).toBe("Als gelesen markieren"); + expect(facts.strings.newMail).toBe("Neue E-Mail"); + expect(facts.strings.noSubject).toBe("(kein Betreff)"); + expect(facts.strings.failed).not.toBe(""); + }); + + it("says so when there is no archive folder, rather than inventing one", async () => { + // The worker draws no Archive button on a null. An account without an + // archive is not a reason to file mail somewhere else. + const { store } = fakeCaches(); + await publishWorkerFacts("a1", null); + expect(written(store).archiveId).toBeNull(); + }); + + it("writes nothing before there is an account", async () => { + const { cache } = fakeCaches(); + await publishWorkerFacts(null, null); + expect(cache.put).not.toHaveBeenCalled(); + }); + + it("does not throw where the browser has no cache storage", async () => { + vi.stubGlobal("caches", undefined); + await expect(publishWorkerFacts("a1", "mb-archive")).resolves.toBeUndefined(); + }); + + it("carries every string the worker looks up", async () => { + // The worker reads these by name and shows `undefined` for a missing one, + // which is the kind of thing that only appears on somebody's lock screen. + const { store } = fakeCaches(); + await publishWorkerFacts("a1", "mb-archive"); + const facts = written(store); + for (const k of ["newMail", "newMessage", "noSubject", "archive", "markRead", "failed"] as const) { + expect(facts.strings[k], `missing ${k}`).toBeTruthy(); + } + }); +}); diff --git a/web/src/lib/swFacts.ts b/web/src/lib/swFacts.ts new file mode 100644 index 0000000..90eda94 --- /dev/null +++ b/web/src/lib/swFacts.ts @@ -0,0 +1,69 @@ +/* + * What the service worker cannot work out for itself. + * + * The worker can act on mail — see the note on `jmap()` in sw.js — but it + * cannot read a catalogue or a store. It is plain JavaScript copied into the + * build, outside the bundle, with no i18n and no idea which mailbox is the + * archive. Both of those are things a tab knows and can simply write down. + * + * So the app leaves a short briefing in the same cache it uses for every other + * handoff, and the worker reads it when a notification arrives. Where there is + * none, the worker offers no actions at all rather than guessing: an untitled + * button that files mail somewhere is worse than a notification you have to + * open. + * + * That means the actions appear once ihasmail has been opened since the worker + * was installed, which is the same condition background notifications already + * carry — a push subscription has to be renewed from a tab too. + */ +import { withBase } from "./basePath"; +import { SW_CACHE_NAME } from "./swCache"; +import { t } from "./i18n"; + +export const FACTS_KEY = "/ihasmail-worker-facts"; + +export interface WorkerFacts { + /** The account the notifications are about. */ + accountId: string; + /** Where Archive files to; null where the account has no archive folder. */ + archiveId: string | null; + /** The worker's own user-visible text, in the language this tab is in. */ + strings: { + newMail: string; + newMessage: string; + noSubject: string; + archive: string; + markRead: string; + failed: string; + }; +} + +/** + * Write the briefing. + * + * Called again whenever what is in it could have changed — the language, the + * account, the archive folder — because it is what the worker will still be + * reading in a week's time. Rewriting it is one cache put; there is nothing to + * gain by working out whether it differs. + */ +export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise { + if (typeof caches === "undefined" || !accountId) return; + const facts: WorkerFacts = { + accountId, + archiveId, + strings: { + newMail: t("New mail"), + newMessage: t("New message"), + noSubject: t("(no subject)"), + archive: t("Archive"), + markRead: t("Mark as read"), + failed: t("Could not do that — open ihasmail and try again"), + }, + }; + try { + const cache = await caches.open(SW_CACHE_NAME); + await cache.put(withBase(FACTS_KEY), new Response(JSON.stringify(facts), { headers: { "content-type": "application/json" } })); + } catch { + /* no cache storage: the worker falls back to a notification with no actions */ + } +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 05f93ab..2327417 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -892,6 +892,8 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Neue Nachricht", + "New mail": "Neue E-Mail", + "Could not do that — open ihasmail and try again": "Nicht möglich – öffnen Sie ihasmail und versuchen Sie es erneut", "Sending…": "Wird gesendet…", "Saving…": "Wird gespeichert…", "Error": "Fehler", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 9a5bedf..8989ca6 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -865,6 +865,8 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Mensaje nuevo", + "New mail": "Correo nuevo", + "Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo", "Sending…": "Enviando…", "Saving…": "Guardando…", "Error": "Error", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 4017cb9..740a5c6 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -870,6 +870,8 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Nouveau message", + "New mail": "Nouveau courrier", + "Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez", "Sending…": "Envoi…", "Saving…": "Enregistrement…", "Error": "Erreur", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 6076028..fbb761f 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -873,6 +873,8 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "新規メール", + "New mail": "新着メール", + "Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください", "Sending…": "送信中…", "Saving…": "保存中…", "Error": "エラー", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 81ee9d6..a3a63e2 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -861,6 +861,8 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Nieuw bericht", + "New mail": "Nieuwe e-mail", + "Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw", "Sending…": "Bezig met verzenden…", "Saving…": "Bezig met opslaan…", "Error": "Fout", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index b899c88..533daea 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -868,6 +868,8 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Nova mensagem", + "New mail": "Novo e-mail", + "Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente", "Sending…": "Enviando…", "Saving…": "Salvando…", "Error": "Erro", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 39a86e3..4f3bec9 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -867,6 +867,8 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Новое письмо", + "New mail": "Новое письмо", + "Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку", "Sending…": "Отправка…", "Saving…": "Сохранение…", "Error": "Ошибка", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 606a8c2..509e6cd 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -861,6 +861,8 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Новий лист", + "New mail": "Новий лист", + "Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу", "Sending…": "Надсилання…", "Saving…": "Збереження…", "Error": "Помилка", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index fa73af0..ac294ae 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -872,6 +872,8 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "新邮件", + "New mail": "新邮件", + "Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试", "Sending…": "正在发送…", "Saving…": "正在保存…", "Error": "错误",