diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22a92a2..5051716 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: # Without this there is no way to re-run a check that never started: a run # GitHub queues and then orphans -- as it did to every run created during the # Actions outage on 2026-08-26 -- can be neither rerun ("already running") - # nor cancelled ("already completed"), and the workflow has no other trigger + # nor canceled ("already completed"), and the workflow has no other trigger # to reach for. Useful too for putting a check on a commit that predates a CI # change, without pushing an empty commit to move it. workflow_dispatch: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c50e2a5..a734e8d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,7 @@ # `ghcr.io/coffey-labs/ihasmail:latest` for a long time, and nothing ever # pushed it: `docker pull` answered `denied`, because the package did not # exist. This is the workflow that makes those instructions true. It is also -# the prerequisite for the self-hosted app catalogues -- TrueNAS and Unraid +# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid # both install by pulling an image and neither builds from source. # # FIRST RUN: a package GHCR creates for the first time is **private**, even in @@ -44,7 +44,7 @@ on: type: boolean default: false # Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then - # orphans can be neither rerun nor cancelled, and this workflow otherwise + # orphans can be neither rerun nor canceled, and this workflow otherwise # only fires on a release -- which is not something to cut twice because a # runner died. `ref` also allows publishing an image for a tag that predates # this workflow, which is how the first one gets built. diff --git a/.palette-sources/palettes-upstream.md b/.palette-sources/palettes-upstream.md index 5a530c5..85fca2e 100644 --- a/.palette-sources/palettes-upstream.md +++ b/.palette-sources/palettes-upstream.md @@ -84,23 +84,23 @@ violet #6c71c4 · blue #268bd2 · cyan #2aa198 · green #859900 The accents are shared by both modes by design. Two tiers are **derived**: the sunken dark surface #001f28 (below base03) and the raised light surface #fffdf6 (above base3), neither of which Solarized publishes, plus the two -rule colours #0d4552 and #e6dfc8. +rule colors #0d4552 and #e6dfc8. ## Everforest — sainnhe/everforest, MIT (palette.md), medium contrast ### Dark bg_dim #232a2e · bg0 #2d353b · bg1 #343f44 · bg3 #475258 -fg #d3c6aa · grey1 #859289 +fg #d3c6aa · gray1 #859289 red #e67e80 · orange #e69875 · yellow #dbbc7f · green #a7c080 · aqua #83c092 blue #7fbbb3 · purple #d699b6 ### Light bg_dim #efebd4 · bg0 #fdf6e3 · bg3 #e6e2cc · bg5 #bdc3af -fg #5c6a72 · grey1 #939f91 +fg #5c6a72 · gray1 #939f91 red #f85552 · orange #f57d26 · yellow #dfa000 · green #8da101 · aqua #35a77c blue #3a94c5 · purple #df69ba Light uses bg_dim as the page and bg0 as the raised surface, so the card the -reader looks at is the colour Everforest calls its background. +reader looks at is the color Everforest calls its background. ## Kanagawa — rebelot/kanagawa.nvim, MIT (lua/kanagawa/colors.lua) ### Wave (dark) @@ -118,7 +118,7 @@ lotusGreen #6f894e · lotusYellow #77713f · lotusPink #b35b79 ## Ayu — ayu-theme/ayu-colors, MIT (themes/dark.yaml, themes/light.yaml) The YAMLs give the base palette and the surfaces as literals but express syntax roles as references (`$palette.indigo.l2`), and the resolved files are not -committed. The two signature accents are taken from the same organisation's +committed. The two signature accents are taken from the same organization's MIT-licensed ayu-theme/vscode-ayu build. ### Dark @@ -134,7 +134,7 @@ red #F07171 · orange #FA8532 · yellow #EBA400 · green #86B300 · teal #4CBF99 indigo #55B4D4 · blue #22A4E6 · purple #A37ACC · accent #F29718 (vscode-ayu) ## Primer — primer/primitives, MIT (src/tokens/base/color/{dark,light}) -Named "Primer" after the design system. The colour values are MIT; "GitHub" +Named "Primer" after the design system. The color values are MIT; "GitHub" and the Invertocat are trademarks, and nothing here is endorsed by them. ### Dark diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee5ec63..783d893 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,11 +74,11 @@ failing, so an untranslated string is invisible until somebody reading that language finds it. **Any change that adds or alters a user-visible string adds work in all nine -catalogues.** Say so explicitly in the PR — how many keys, and the fallback +catalogs.** Say so explicitly in the PR — how many keys, and the fallback count before and after — and say so just as explicitly when a change adds none, so it is never left to be inferred. -#### The catalogue key for a plural is the `other` form +#### The catalog key for a plural is the `other` form `plural()` looks the entry up by `forms.other`, so a call site written as @@ -86,13 +86,13 @@ so it is never left to be inferred. plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" }) ``` -is keyed on **`"Deleted {n} contacts"`**. Keying the catalogue on the `one` +is keyed on **`"Deleted {n} contacts"`**. Keying the catalog on the `one` form type-checks, builds, passes every test, and silently falls back to English in all nine languages. Nothing errors. The only signal is the fallback count going up, so read it: ```sh -npm run i18n:check # literals wrapped, and catalogue health; exits 1 on a finding +npm run i18n:check # literals wrapped, and catalog health; exits 1 on a finding node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back ``` @@ -152,7 +152,7 @@ browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ─ JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image ``` -- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitiser, search parser, Sieve codec, locale-aware dates, vCard, …). +- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitizer, search parser, Sieve codec, locale-aware dates, vCard, …). - `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos. Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, @@ -179,10 +179,10 @@ RFC 8984's. | `MOCK_EDITION=enterprise` | Reports Enterprise, which Tenants needs | It tracks the current Stalwart release rather than 0.16 in general, and each -behaviour is confirmed against a real server before it is copied here — the +behavior is confirmed against a real server before it is copied here — the comments say which version and on what date. Where a release changes something a client can see, the mock changes with it, and the test that pinned the old -behaviour is rewritten rather than deleted, so the reversal stays on the record. +behavior is rewritten rather than deleted, so the reversal stays on the record. #### Version numbers diff --git a/FEATURES.md b/FEATURES.md index c250e5a..0d550f7 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -13,7 +13,7 @@ questions: | [docs.ihasmail.org](https://docs.ihasmail.org) | How to install, configure and drive each of these | Written against the tree at Stalwart **0.16.22**, which is the version the live -instance runs. Behaviours carrying an older version below were checked against +instance runs. Behaviors carrying an older version below were checked against that one and have not changed since; where a later release changed something, the entry says so and names both. 0.16.22 changed nothing described here: its client-visible changes are in what `CalendarEvent/get` and `ContactCard/get` @@ -103,7 +103,7 @@ every drag. archives and left deletes by default, which is what the mail app the phone came with already does. Either direction can be set to archive, delete, report spam, read/unread, star or move to… — or to nothing, which turns that - direction off. The coloured strip revealed behind the row names what will + direction off. The colored strip revealed behind the row names what will actually happen *in the folder it is happening in*: "Delete forever" out of Deleted Items, "Not spam" inside Junk Mail. Where an action is meaningless there — archiving out of the archive, calling your own drafts @@ -143,14 +143,14 @@ it is silently nothing there. The arithmetic lives in `web/src/lib/touch.ts`, away from the components and under test, because the numbers are the whole thing. The axis lock is -deliberately biased towards the vertical: scrolling is what a finger on a +deliberately biased toward the vertical: scrolling is what a finger on a message list is doing almost every time, and a scroll misread as a swipe grabs the list out from under the reader, while a swipe misread as a scroll costs one more attempt. A drag that is merely more sideways than not stays a scroll. ## The message list -- **Virtualised** — rows are windowed with `@tanstack/react-virtual`, so a +- **Virtualized** — rows are windowed with `@tanstack/react-virtual`, so a folder of 100,000 messages scrolls at the same speed as one of ten. Row height follows density and the one- or two-line layout. - **Infinite scroll** with server-side paging, 50 at a time by default. @@ -236,7 +236,7 @@ for that one, and the dialog says so. ## Folders -Real JMAP mailboxes, with the server's roles honoured. +Real JMAP mailboxes, with the server's roles honored. - Create, rename, create a subfolder, delete (with or without its mail). - **Drag a folder onto another** to reparent it. Folders with a server role @@ -246,18 +246,18 @@ Real JMAP mailboxes, with the server's roles honoured. unsubscribed folder still exists and still receives; it is just out of the way. Inbox cannot be hidden. - **Mark all as read**, optionally including subfolders. -- **Folder colours**, per mailbox id. +- **Folder colors**, per mailbox id. - **Unread counts** per folder, live. - **Storage quota** bar under the tree where the server reports one. - Rights are respected per folder: rename, delete, create-child and share each - grey out when `myRights` says no. + gray out when `myRights` says no. - A folder in the address that this account does not have says *this folder is missing*, rather than drawing an empty folder — a stale link should not read as a folder that emptied itself. ## Labels -Labels are **IMAP keywords** with a colour and a display name kept in settings. +Labels are **IMAP keywords** with a color and a display name kept in settings. Because they are keywords, every other client that reads the mailbox sees them, and they survive ihasmail entirely. A message can carry any number. They are managed in Settings › Labels, applied from `l` or the context menu, and @@ -311,7 +311,7 @@ same query string — so what it builds can be read, edited and learned from. ## Reading a message -- **Sanitised HTML**, rendered inside a **Shadow DOM** so the sender's CSS +- **Sanitized HTML**, rendered inside a **Shadow DOM** so the sender's CSS cannot reach the app. DOMPurify strips scripts, event handlers, forms and anything that could navigate the top window. - **Remote images blocked by default**, with a banner offering *Show images* or @@ -370,14 +370,14 @@ same query string — so what it builds can be read, edited and learned from. since the filter applies policy ihasmail cannot see. Mail that arrived without these headers shows nothing. - **Message body theming** is off by default — sender HTML is left exactly as it - was designed, on a light card. One setting lets mail that brings no colours of + was designed, on a light card. One setting lets mail that brings no colors of its own follow the app's theme instead. That is a low bar in practice: one `color:#FFFFFF` on one button label opts a whole message out, so for mail built from a template it changed nothing. A second setting, off unless the - first is on, forces the theme over the sender's own colours. It tells a + first is on, forces the theme over the sender's own colors. It tells a *sheet* the design sits on, like a white wrapper table, from a *painted surface* like a button or a banner, by relative luminance: the first is - neutralised so the bright card goes away, the second is kept whole so its + neutralized so the bright card goes away, the second is kept whole so its label stays readable on it. Nothing the sender wrote is removed, so the switch is reversible, and print is unaffected either way. @@ -397,7 +397,7 @@ same query string — so what it builds can be read, edited and learned from. - **Invitations (iTIP)** render an invite card: what, when, where, the guest list with each person's status, and Yes / Maybe / No. The reply is written to - the event and sent back to the organiser. Cancellations are recognised too. + the event and sent back to the organizer. Cancellations are recognized too. - **vCard attachments** render a card offering to add the person to an address book. - **Right-click anyone named** in the message — From, To, Cc, Bcc or Reply-To — @@ -427,9 +427,9 @@ Requesting one on your own outgoing mail is a separate switch. ## Composing **Multiple composers at once**, floating in a dock at the bottom right, each -minimisable and maximisable; full-screen on mobile. +minimizable and maximizable; full-screen on mobile. -- **Rich text**: bold, italic, underline, strikethrough, text colour, highlight, +- **Rich text**: bold, italic, underline, strikethrough, text color, highlight, font size, alignment, bulleted and numbered lists, indent/outdent, blockquote, code block, links (`Ctrl+K`), inline images, an emoji picker, and remove formatting. Tab and Shift+Tab indent inside the body. @@ -468,7 +468,7 @@ minimisable and maximisable; full-screen on mobile. - **Attachments** by picking or dragging onto the composer, with progress per file and the size limit the server states (`MAX_UPLOAD_BYTES`, 50 MB by default). A pasted image is inserted inline instead, and pasted HTML is - sanitised on the way in. + sanitized on the way in. - **Attach from Files** — anything the server already holds attaches with **no upload at all**, however large. A file from someone else's shared folder is copied to your account first, because a message can only carry blobs from the @@ -509,7 +509,7 @@ out whether or not ihasmail is open, or ever opened again. Held messages wait in a **Scheduled** folder ihasmail maintains itself (JMAP has no role for one), and reconciles when you next open it: released messages move -to Sent, cancelled ones back to Drafts. The picker offers presets and an exact +to Sent, canceled ones back to Drafts. The picker offers presets and an exact date and time, bounded by the maximum delay the server advertises. > If Stalwart's `futureRelease` is not configured, a "scheduled" message is sent @@ -536,14 +536,14 @@ are settings. The sidebar keeps three groups apart: -- **My calendars** — yours, each with a colour, each hideable with a click. +- **My calendars** — yours, each with a color, each hideable with a click. - **Shared with me** — other people's, once added. - **Available to add** — shared with you but not yet added, with a plus beside each. An unadded calendar draws nothing. This is deliberate: the server reports every collection in an account you can reach, whether or not anyone meant to share it, so being handed one is not evidence that it was offered. -Right-click your own to rename, recolour, share, stop sharing or delete; +Right-click your own to rename, recolor, share, stop sharing or delete; right-click one of someone else's to remove it from your view, which changes nothing for anybody else. @@ -551,7 +551,7 @@ nothing for anybody else. events), from the calendar's own menu, into that calendar. The events are filed rather than scheduled: no invitations go out to anyone named in them. - **Re-importing updates rather than duplicates**, as a contacts import does. - An event is recognised by its UID, per calendar, and what the file carries + An event is recognized by its UID, per calendar, and what the file carries wins -- so a corrected export corrects what the first attempt got wrong. Two things are deliberately left alone: **who accepted**, and **edits to a @@ -566,11 +566,11 @@ nothing for anybody else. since the last import does not arrive, because nothing here can tell that apart from an answer given in ihasmail. And an import still sends no scheduling messages, so an event a re-import moves is moved *here* -- - everybody else's copy still says the old time until whoever is organising + everybody else's copy still says the old time until whoever is organizing sends the update from the event itself. - **Subscribed calendars** by URL — a timetable, a rota, a public holiday list. Added in Settings › Calendar & contacts, read-only, and shown beside your own - with their own colour. + with their own color. **Nothing is stored.** The document is fetched when you open the calendar and parsed in the browser; the server keeps no copy, no cache and no schedule, @@ -608,7 +608,7 @@ nothing for anybody else. They cannot be edited or deleted, and that falls out of the design rather than being special-cased: the virtual calendar reports no write rights, so every control that asks before offering Edit or Delete already declines. The - store refuses a synthesised id as well, whatever calls it. + store refuses a synthesized id as well, whatever calls it. A card that records only a day and month — the common case — gets a birthday with no age rather than no birthday. And 29 February falls on the 28th in a @@ -623,16 +623,16 @@ empty space offers a timed or all-day event at that moment, or *Go to day* / The editor covers title, start and end (all-day or timed, with a time zone), calendar, location, meeting link, guests, description, reminders, repeat, -status (confirmed / tentative / cancelled), show-as (busy / free), visibility -(default / private / secret), category and colour. +status (confirmed / tentative / canceled), show-as (busy / free), visibility +(default / private / secret), category and color. - **Recurrence** — none, daily, weekly, weekdays, monthly, yearly, or a custom builder: interval, by-weekday, by-month-day, and an end by count or by date. - **Reminders** — one or more alerts before the start, with a default in settings. -- **Colour categories**, Outlook-style: named colours managed in Settings › +- **Color categories**, Outlook-style: named colors managed in Settings › Calendar, assigned from the editor or the context menu, and stored as - JSCalendar `categories` so other clients see them. (The per-event colour - picker that predated them is gone; a colour comes from the category, or the + JSCalendar `categories` so other clients see them. (The per-event color + picker that predated them is gone; a color comes from the category, or the calendar.) - **Duplicate** an event from the context menu. - **Create event…** from a message, in its context menu and its ⋮ menu (and, @@ -649,7 +649,7 @@ status (confirmed / tentative / cancelled), show-as (busy / free), visibility ## Attendees, invitations and free/busy Invitations go out as iTIP when guests are added, replies come back and are -applied to the event, and cancelling notifies the guests. Guests are added by +applied to the event, and canceling notifies the guests. Guests are added by name or address with the same autocomplete the composer uses. Where the server implements `Principal/getAvailability`, the event editor grows @@ -756,24 +756,24 @@ JMAP Contacts and JSContact. afterwards is what the server confirmed rather than what was asked for. - **Letter index** down the list, with `#` for everything that does not start with a letter. -- **Search** across name, address, organisation and notes, in one book or all. +- **Search** across name, address, organization and notes, in one book or all. - **vCard import** through `ContactCard/parse` (a file of any number of cards), and **export** of one card or the whole book as `.vcf`. - **LDIF import**, for address books coming from SOGo, Thunderbird or an LDAP directory. Nothing on the server reads LDIF, so the file is read here: RFC 2849 for the syntax, [Mozilla's address book schema][ldif-schema] for what the attributes mean, which is the one such exports almost always use. Work and - home addresses, every phone kind, second email, organisation and units, job + home addresses, every phone kind, second email, organization and units, job title, nickname, web pages and the custom fields all come across. The import control takes either format and decides by what is in the file, not by what it is called. -- **Re-importing updates rather than duplicates.** A vCard is recognised by its +- **Re-importing updates rather than duplicates.** A vCard is recognized by its UID; an LDIF entry, whose schema has none, by its distinguished name. The card already here is merged with the file's version -- what the file carries wins, what it does not mention is left alone -- so a corrected export can correct what the first attempt got wrong. Matching is per address book, which is also how two directories that each hold a `cn=John Smith` stay two people. An entry - no longer recognisable, because its `dn` moved between exports, is imported + no longer recognizable, because its `dn` moved between exports, is imported again and counted: *"3 of them look like contacts you already had."* [ldif-schema]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema @@ -898,7 +898,7 @@ individual rights by hand. Preferences live in a `settings.json` in the account's own JMAP Files, beside the signature images. So identity, signatures, locale, date and time formats, -theme, labels, templates, folder colours, trusted image senders and added shares +theme, labels, templates, folder colors, trusted image senders and added shares are the same wherever you sign in, private windows included — and they are backed up with the mail store, because they *are* in the mail store. ihasmail still stores nothing of its own. @@ -923,14 +923,14 @@ not reach another that already has ihasmail open until it signs in again. | --- | --- | | **General** | Reading pane, mark-as-read delay, auto-advance, conversation view, snippets, avatars; compose format, quoting, signature placement, spell check; time zone, week start, language & region, date format, time format; `mailto:` handler; export / import / reset | | **Privacy & safety** | Remote images and the senders trusted with them, read receipts asked for and answered; the three warnings and the domains they measure against; undo-send window, attachment reminder, confirm-before-delete | -| **Appearance** | Theme, accent colour, density, font size, sidebar, swipe actions, interface language | +| **Appearance** | Theme, accent color, density, font size, sidebar, swipe actions, interface language | | **Identities & signatures** | Addresses, names, Reply-To, HTML signatures, the default, and which to hide from the picker | | **Filters & rules** | The visual builder and raw Sieve editor | | **Out of office** | Vacation response | -| **Folders** | Create, rename, colour, subscribe | -| **Labels** | Keyword, display name, colour | +| **Folders** | Create, rename, color, subscribe | +| **Labels** | Keyword, display name, color | | **Templates** | Named subject + body | -| **Calendar & contacts** | Colour categories, working hours, default view, default duration, default reminder | +| **Calendar & contacts** | Color categories, working hours, default view, default duration, default reminder | | **Notifications** | In-tab notifications, notify-when-closed (Web Push), sound | | **Security & sessions** | Password, two-factor state, app passwords, active webmail sessions | | **Keyboard shortcuts** | The full list, grouped | @@ -940,7 +940,7 @@ not reach another that already has ihasmail open until it signs in again. between them is worth stating because two similar words in one nav is how a menu becomes something people hunt through. Security & sessions is credentials and access: password, two-factor state, app passwords, live sessions. Privacy & -safety is how the app behaves towards the reader and towards senders: what +safety is how the app behaves toward the reader and toward senders: what loads, what leaks, and what asks before it happens. These had been spread through General, which had grown five unrelated headings — remote images filed under "Reading", the read-receipt policy under "Composing", the undo-send window @@ -1010,12 +1010,12 @@ is why they are two settings and not one. | | | | --- | --- | -| English | the source language, and what every other catalogue falls back to | +| English | the source language, and what every other catalog falls back to | | Deutsch · Español · Français · Nederlands · Português (Brasil) | Beta | | Русский · Українська · 简体中文 · 日本語 | Beta | **All nine translations are marked Beta, and the label is not modesty.** -The catalogues were produced by AI against standard dictionaries and have not +The catalogs were produced by AI against standard dictionaries and have not been read by anybody who speaks the language. That is stated in Settings, next to a link for reporting anything that reads wrongly, because the alternative — shipping them quietly — would ask people to trust text nobody has checked. A @@ -1025,7 +1025,7 @@ deliberate act by a person and not something a percentage earns. Two things follow from the design rather than the translation: - **A missing entry renders its English source.** So deleting a bad line is a - valid fix, not a regression, and a catalogue is never in a half-broken state. + valid fix, not a regression, and a catalog is never in a half-broken state. - **Plurals are asked for, never assumed.** `Intl.PluralRules` decides the form, so Russian and Ukrainian get their three (1 письмо, 2–4 письма, 5+ писем) and Japanese and Chinese get the one they actually have — with counters doing the @@ -1037,7 +1037,7 @@ The interface language also feeds the *automatic* date locale, so choosing page that is already in the reader's language — and accepting that offer is what rewrites the DOM underneath React. -Only languages with a catalogue shipped appear in the picker. A language +Only languages with a catalog shipped appear in the picker. A language offered without strings behind it would leave the page claiming to be in a language it is not, which is worse than not offering it: it stops a browser offering to translate a page the reader cannot read. @@ -1061,28 +1061,28 @@ at two. | **Ayu** | | | **Kanagawa** | Wave, with Lotus as its light half | | **Everforest** | The medium-contrast variant of each side | -| **Primer** | The colours behind GitHub's design system. Named for the system, not for GitHub, which has not endorsed anything here | +| **Primer** | The colors behind GitHub's design system. Named for the system, not for GitHub, which has not endorsed anything here | Every one has both halves, so the top-bar toggle only ever changes the side and -never the colours. Accent colours still sit on top of any of them. +never the colors. Accent colors still sit on top of any of them. The ten borrowed palettes are the work of their own projects and are used -under the MIT licence — see [NOTICE](NOTICE). Only the published colour values +under the MIT license — see [NOTICE](NOTICE). Only the published color values are used, taken from each project's own repository; the values as fetched are recorded in `.palette-sources/palettes-upstream.md`. **The shades between those values are derived, and every one is checked.** ihasmail needs about thirty tokens and these projects publish between twelve and twenty, so the tiers in between are computed by -`scripts/build-palettes.py`, which then measures every text colour against the +`scripts/build-palettes.py`, which then measures every text color against the surface it sits on — 4.5:1 for prose, 3:1 for borders and marks — and lifts -anything that falls short, towards white on a dark ground and towards black on +anything that falls short, toward white on a dark ground and toward black on a light one so the hue survives. The script refuses to write a palette that would not pass. That check is not a formality. **Twenty-one of the twenty-two palette halves needed at least one lift**, because these palettes are designed for code -editors rather than for prose at this size: Dracula's comment grey is 3.03:1 on +editors rather than for prose at this size: Dracula's comment gray is 3.03:1 on its own background, and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as published would have quietly ended the WCAG AA claim two sections down. @@ -1266,7 +1266,7 @@ Stalwart shows the server's English until it is translated. ## Tenants -A tenant is a separate organisation on the same server — its own people, +A tenant is a separate organization on the same server — its own people, domains and limits, and an administrator who manages only what is in it. It is a Stalwart Enterprise feature. On a server that does not report Enterprise — or reports no edition at all — the page is only the notice *Tenants are a Stalwart @@ -1336,7 +1336,7 @@ one question the sign-in page already asks about where it is being used. It is enforced the same way as the switch below: an untrusted session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. The menu still shows **Administration** to an administrator in that -session, greyed out, with the reason and what to do about it — signing in again +session, grayed out, with the reason and what to do about it — signing in again with the box ticked — rather than losing the entry without a word. All the server tells that session is that the account administers, never what it may do. @@ -1463,7 +1463,7 @@ server settings is deliberately out of scope. 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 + What it cannot reach is a catalog. 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 @@ -1550,7 +1550,7 @@ costs something to get wrong is the one that assumes the machine is yours. | Administration | unavailable | available, if the role allows it | Local storage is gated on that answer for **reads** as well as writes — a -machine trusted once still has residue, and honouring it would let a previous +machine trusted once still has residue, and honoring it would let a previous session's data surface in a later untrusted one. Signing out clears the settings cache and recent addresses and tears down the push subscription, whichever answer was given. @@ -1614,10 +1614,10 @@ This is S/MIME only, and it stops at reading: nothing here signs, encrypts or decrypts anything. **What it checks.** For a `multipart/signed` message carrying a PKCS#7 -signature, the exact bytes of the signed part — headers included, canonicalised +signature, the exact bytes of the signed part — headers included, canonicalized to CRLF — are hashed and compared against the `messageDigest` the signature covers, and the signature over the signed attributes is verified with WebCrypto -against the certificate travelling inside the message. RSA (PKCS#1 v1.5) and +against the certificate traveling inside the message. RSA (PKCS#1 v1.5) and ECDSA over P-256, P-384 and P-521 are supported, with SHA-256, SHA-384 or SHA-512. @@ -1634,12 +1634,12 @@ authority: | what happened | what you see | |---|---| -| first signed message from this address | *"Signed by X, seen here for the first time"* — grey, and deliberately not congratulatory | +| first signed message from this address | *"Signed by X, seen here for the first time"* — gray, and deliberately not congratulatory | | same certificate as before | *"the same signer as before"* — the only case that gets a tick | | **different certificate than before** | **loud**: both names, and told to check by some other route | | valid signature, certificate for a different address | **loud**: the signature is not for this sender | | body changed after signing | **loud**: the signature does not check out | -| signed, but uncheckable | grey, and careful to say *could not check* rather than *did not check out* | +| signed, but uncheckable | gray, and careful to say *could not check* rather than *did not check out* | The pins live in the account's settings file rather than in the browser, so the same correspondent is not greeted as new on every device — which is what trains @@ -1689,7 +1689,7 @@ docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ... ``` `IMMUTABLE=1` is an **assertion the server checks at startup**, not a switch -that changes behaviour. It refuses to boot if `SESSION_FILE` is still set, or if +that changes behavior. It refuses to boot if `SESSION_FILE` is still set, or if the filesystem it is installed on turns out to be writable after all. Without it, the same misconfiguration is silent — sessions are held in memory and persisting them is best-effort, so a read-only `/data` costs one warning at the diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 1e1f41a..00f4f8e 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -54,7 +54,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for - **The Basic credential ihasmail proxies with reaches the admin `x:` methods**, as it already reached the self-service ones. No separate token is involved. - **An account reads back in the shapes the code expects**: `credentials` as `{"0": {"@type": "Password", …}}`, aliases and group memberships as objects, the disk limit under `quotas.maxDiskQuota`. - **A new domain gets automatic DKIM straight away** — an Ed25519 and an RSA key, both `active`, with their records already in the zone file — and manual DNS and certificates. - - **`dnsZoneFile` is BIND text**, one record per line as `name IN TYPE value`, with a long TXT record split into a parenthesised run of quoted chunks. A throwaway domain's file held 18 records and 3 continuation lines; every record parsed and the panel showed 18 rows. The production domains also carry TLSA records, which show as rows like any other. + - **`dnsZoneFile` is BIND text**, one record per line as `name IN TYPE value`, with a long TXT record split into a parenthesized run of quoted chunks. A throwaway domain's file held 18 records and 3 continuation lines; every record parsed and the panel showed 18 rows. The production domains also carry TLSA records, which show as rows like any other. - **`x:DkimSignature/query` accepts a `domainId` filter.** - **`catchAllAddress` wants a whole address.** A bare local part is refused with `invalidPatch`, *"Invalid email address"*. - **A domain its keys still name cannot be destroyed**: `objectIsLinked`, with `linkedObjects` listing each as `{"object": "DkimSignature", "id": …}` and no description. Removing through the panel destroys the keys first and then the domain; both were gone afterwards. @@ -90,7 +90,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for - **A role another role builds on cannot be deleted**: `objectIsLinked`, `objectId` `{"object": "Role", …}`, `linkedObjects` naming the child. - **The defaults** read from `x:Authentication`: users get User; groups get Group; tenant administrators get Tenant Administrator and User; administrators get System Administrator and User. - **The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs towards refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` through ihasmail's server was confirmed on production after the deploy (2026-09-15, v2026.9.15+pr364)**: `/api/admin/permissions` answered 200 with all 661 permissions, the same list as the 0.16.22 snapshot, and the Roles picker drew them under 60 headings. The four bootstrap roles grant 244 (User), 229 (Group), 50 (Tenant Administrator) and 452 (System Administrator) once their trees are followed. + **The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs toward refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` through ihasmail's server was confirmed on production after the deploy (2026-09-15, v2026.9.15+pr364)**: `/api/admin/permissions` answered 200 with all 661 permissions, the same list as the 0.16.22 snapshot, and the Roles picker drew them under 60 headings. The four bootstrap roles grant 244 (User), 229 (Group), 50 (Tenant Administrator) and 452 (System Administrator) once their trees are followed. - **Tenants were built from the 0.16.22 source, its schema and the mock, then tried on the live server (2026-09-15)** with throwaway `ihasmail-tenant-test` tenants, a throwaway role, two throwaway lists and a throwaway domain, all removed. The live run changed the design twice: @@ -102,15 +102,15 @@ works the same way — and dropped where 0.15 was the whole subject. Support for Still from source only: that only a caller outside every tenant may set `memberTenantId` (`set.rs` passes `can_set_tenant` only when the token has no tenant), and that a tenant administrator's queries are scoped to the tenant. On a server that does not report Enterprise the Tenants page is only its notice. -- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalogue's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart. +- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalog's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart. -- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognised by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why. +- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognized by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why. - **Administration is off for a device not marked as your own, and for an installation that says so.** Both are enforced by the server rather than hidden by the menu: such a session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. That is worth stating because the proxy otherwise forwards whatever the browser sends, and before these gates an administrator's console could make any registry call their role allowed. For a session that may not administer, the proxy reads a request body only when it could name a registry method — a `"x:` in the text, or a `\u` escape that could spell one — so ordinary mail traffic is forwarded untouched. -- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value. +- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localize and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value. -- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `
-
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 7ec185f..87d35f0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -14,7 +14,7 @@ See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
-- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalogue without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
+- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalog without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
- **Signing and encrypting mail.** *Reading* a signature is built: S/MIME signed mail is checked as it is read, and the signer is remembered so a change is called out — see [Checking a signature](FEATURES.md#checking-a-signature). What is not built is anything that produces a signature or touches ciphertext, and the reason is not Stalwart. This is client work over the message body: JMAP hands over the MIME blob and the rest is ours.
@@ -26,8 +26,8 @@ See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about
**Encryption at rest is refused rather than deferred.** Stalwart offers it as `encryptionAtRest`, a field on `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object whatever the docs suggest, and its value is a typed object (`{"@type": "Disabled"}`) rather than a bare string. It is self-service, needs no administrator, and would be easy to offer. It will not be: turning it *off does not decrypt what is already there*. Every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key, so switching it on is a one-way door — and a toggle that reads as "make my mail safer" while quietly being irreversible is the wrong thing to hand an ordinary user.
- **Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponised in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
+ **Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponized in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
- So if one of the two gets built here it is S/MIME, because it is the one that is *more* deployed in the places that pay for software: native in Outlook and Apple Mail, and routine in defence, healthcare, finance and government, where a CA issues and revokes certificates that an IT department can actually administer. The web of trust never became something anybody could run at scale.
+ So if one of the two gets built here it is S/MIME, because it is the one that is *more* deployed in the places that pay for software: native in Outlook and Apple Mail, and routine in defense, healthcare, finance and government, where a CA issues and revokes certificates that an IT department can actually administer. The web of trust never became something anybody could run at scale.
Expect the asking to be far out of proportion to the using. A self-hosted webmail for Stalwart draws self-hosters, privacy-minded users and European SMEs, which is about the densest concentration of PGP users left alive — so this will be requested much more often than it would be used, and that is an argument for keeping it here, described honestly, rather than either building it on the strength of the requests or refusing it outright.
diff --git a/docs/screenshots.mjs b/docs/screenshots.mjs
index a137b3c..b828488 100644
--- a/docs/screenshots.mjs
+++ b/docs/screenshots.mjs
@@ -128,7 +128,7 @@ const waitFor = async (jsExpr, what, ms = 15000) => {
* capture — twice, silently, producing a "light" screenshot of the dark theme.
* A MutationObserver puts it back faster than anything can take it away.
*
- * The check is the rendered background colour: the attribute is what lied.
+ * The check is the rendered background color: the attribute is what lied.
*/
const themeTest = (want) => want === "light"
? "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) > 200"
diff --git a/scripts/basePath.mjs b/scripts/basePath.mjs
index 53e6caf..e8fc750 100644
--- a/scripts/basePath.mjs
+++ b/scripts/basePath.mjs
@@ -59,7 +59,7 @@ export function baseUrlOf(basePath) {
*
* The comparison is deliberately not `startsWith(base)`: that would let
* `/mailbox` in under a `/mail` mount and serve it the app shell, which is
- * both wrong and a small open door for a neighbouring site on the same host.
+ * both wrong and a small open door for a neighboring site on the same host.
*/
export function stripBasePath(basePath, pathname) {
const base = normalizeBasePath(basePath);
diff --git a/scripts/build-palettes.py b/scripts/build-palettes.py
index 7f3a36d..946c137 100644
--- a/scripts/build-palettes.py
+++ b/scripts/build-palettes.py
@@ -2,15 +2,15 @@
"""
Generate the palette CSS blocks in web/src/styles/app.css.
-Every colour here comes from the palette's own project (all MIT); the values
+Every color here comes from the palette's own project (all MIT); the values
are recorded in .palette-sources/palettes-upstream.md. What this script adds is
the *derivation*: ihasmail needs thirty-odd tokens and these projects publish
between twelve and twenty, so the tiers in between are computed rather than
-guessed, and every text colour is then checked against the surface it sits on.
+guessed, and every text color is then checked against the surface it sits on.
The check is the reason this is a script and not a hand-written block. ihasmail
claims WCAG AA, and several of these palettes do not meet it as published --
-Dracula's comment grey on its own background is about 3.0:1, well under the 4.5
+Dracula's comment gray on its own background is about 3.0:1, well under the 4.5
that normal text needs. Lifting those tiers by eye is how a claim quietly stops
being true; here it is arithmetic, and the script fails loudly if a token it
emitted would not pass.
@@ -30,7 +30,7 @@ BEGIN = "/* === generated palettes: begin === */"
END = "/* === generated palettes: end === */"
-# ---------------------------------------------------------------- colour maths
+# ---------------------------------------------------------------- color maths
def parse(hex_: str) -> tuple[float, float, float]:
h = hex_.lstrip("#")
@@ -66,18 +66,18 @@ def rgba(hex_: str, alpha: float) -> str:
return f"rgba({r}, {g}, {b}, {alpha})"
-def toward_contrast(colour: str, bg: str, target: float, dark_ui: bool) -> str:
- """Nudge `colour` away from `bg` until it clears `target`.
+def toward_contrast(color: str, bg: str, target: float, dark_ui: bool) -> str:
+ """Nudge `color` away from `bg` until it clears `target`.
- Towards white on a dark background and towards black on a light one, so a
- lifted tier keeps its hue instead of washing out to grey.
+ Toward white on a dark background and toward black on a light one, so a
+ lifted tier keeps its hue instead of washing out to gray.
"""
- if contrast(colour, bg) >= target:
- return colour
+ if contrast(color, bg) >= target:
+ return color
anchor = "#ffffff" if dark_ui else "#000000"
- best = colour
+ best = color
for i in range(1, 101):
- candidate = mix(colour, anchor, i / 100)
+ candidate = mix(color, anchor, i / 100)
best = candidate
if contrast(candidate, bg) >= target:
return candidate
@@ -90,7 +90,7 @@ def toward_contrast(colour: str, bg: str, target: float, dark_ui: bool) -> str:
# ihasmail's own palette has a hand-written dark block further up the file --
# it is the identity this project is painted in, and regenerating it would
-# quietly move colours nobody asked to move. Only its light half is derived
+# quietly move colors nobody asked to move. Only its light half is derived
# here, which is why it appears in LIGHT_ONLY.
LIGHT_ONLY = {"ihasmail"}
@@ -261,17 +261,17 @@ def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], lis
bg, fg = src["bg"], src["fg"]
notes: list[str] = []
- def lift(name: str, colour: str, target: float) -> str:
- out = toward_contrast(colour, bg, target, dark)
- if out != colour:
- notes.append(f"{name} {colour} -> {out} ({contrast(colour, bg):.2f} -> {contrast(out, bg):.2f})")
+ def lift(name: str, color: str, target: float) -> str:
+ out = toward_contrast(color, bg, target, dark)
+ if out != color:
+ notes.append(f"{name} {color} -> {out} ({contrast(color, bg):.2f} -> {contrast(out, bg):.2f})")
return out
# Body text is lifted like every other text tone rather than exempted.
- # Most of these palettes publish a body colour around 4.5:1 -- their own
+ # Most of these palettes publish a body color around 4.5:1 -- their own
# target -- and ihasmail asks 7:1 of the text a reader looks at all day.
# Rejecting a palette over that would have cost five of the six added in
- # 2026-09; nudging the published colour along its own hue costs nothing a
+ # 2026-09; nudging the published color along its own hue costs nothing a
# reader can name, and the shift is recorded in the header of the
# generated block like every other one.
fg = lift("fg", fg, TEXT_ON_BG["fg"])
@@ -365,11 +365,11 @@ def main() -> int:
"/*",
" * Written by scripts/build-palettes.py -- edit the sources there, not here.",
" *",
- " * Every colour is from the palette's own project (all MIT); the published",
+ " * Every color is from the palette's own project (all MIT); the published",
" * values are recorded in .palette-sources/palettes-upstream.md. The tiers",
- " * between them are derived, and every text colour is checked against the",
+ " * between them are derived, and every text color is checked against the",
" * surface it sits on: 4.5:1 for prose, 3:1 for borders and marks. Several",
- " * of these palettes do not meet that as published -- Dracula's comment grey",
+ " * of these palettes do not meet that as published -- Dracula's comment gray",
" * is about 3.0:1 on its own background -- so those tiers are lifted, which",
" * is why this is arithmetic rather than a hand-written block.",
" */",
diff --git a/scripts/i18n-catalog-check.mjs b/scripts/i18n-catalog-check.mjs
index 9f5cfdc..a9dc5c1 100644
--- a/scripts/i18n-catalog-check.mjs
+++ b/scripts/i18n-catalog-check.mjs
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/*
- * Check a catalogue against the strings the code actually asks for.
+ * Check a catalog against the strings the code actually asks for.
*
* Two failures, and only one of them is visible without this.
*
@@ -8,9 +8,9 @@
* as an untranslated word on screen, which somebody will eventually notice.
*
* A *stale* key -- one whose English no longer exists, usually because it was
- * mistyped when the catalogue was written -- is silent. The translation sits
+ * mistyped when the catalog was written -- is silent. The translation sits
* in the file looking correct, is never looked up, and the app renders English
- * for ever. Nothing warns, because a catalogue is only ever read by key.
+ * for ever. Nothing warns, because a catalog is only ever read by key.
*/
/*
* The parser, not the compiler.
@@ -32,13 +32,13 @@ import { readFileSync, globSync } from "node:fs";
/*
* Two sets, because there are two questions and they need different nets.
*
- * `wanted` is what a catalogue *owes*: the strings that actually reach t(),
+ * `wanted` is what a catalog *owes*: the strings that actually reach t(),
* tc() or plural(). Coverage is measured against it, so it has to stay strict
* -- widening it would count every CSS class and JMAP method name as an
* untranslated string.
*
* `seen` is every string literal in the source, and answers only "is this
- * catalogue key still written down anywhere". Stale detection needs the wide
+ * catalog key still written down anywhere". Stale detection needs the wide
* net: a key reaches t() as a variable often enough that a strict set reports
* mostly false alarms.
*/
@@ -70,7 +70,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
if (ts.isJsxText(n)) { const text = n.text.trim(); if (text) seen.add(text); }
/*
* A `label:` in a constant is still a string somebody has to translate --
- * it reaches t() one render later -- so it stays part of what a catalogue
+ * it reaches t() one render later -- so it stays part of what a catalog
* owes, and out of coverage it would flatter the number.
*/
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
@@ -81,10 +81,10 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
const fn = n.expression.text, a0 = n.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
- // tc(context, source) keys the catalogue on both, joined by the same
+ // tc(context, source) keys the catalog on both, joined by the same
// control character tc() uses. Without this the contextual entries all
// looked stale, which is the checker's own false alarm rather than a
- // catalogue problem.
+ // catalog problem.
if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) {
// Only the contextual key is required. The plain one is tc()'s
// fallback, not a second obligation -- asking for both would report
@@ -104,8 +104,8 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
}
/*
- * A catalogue and a picker entry are two halves of one thing, and either half
- * alone is dead weight. A catalogue with no entry in UI_LANGUAGES never
+ * A catalog and a picker entry are two halves of one thing, and either half
+ * alone is dead weight. A catalog with no entry in UI_LANGUAGES never
* reaches a reader -- it builds, it passes every test, and the language simply
* is not offered. That happened to Dutch: the entry was added by a text
* replacement anchored on a line that did not exist on that branch, so it was
@@ -113,17 +113,17 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
*/
const languagesSrc = readFileSync("web/src/lib/languages.ts", "utf8");
const registered = new Set([...languagesSrc.matchAll(/tag:\s*"([\w-]+)"/g)].map((m) => m[1]));
-const catalogues = new Set(globSync("web/src/locales/*.ts").map((f) => f.split("/").pop().replace(".ts", "")));
+const catalogs = new Set(globSync("web/src/locales/*.ts").map((f) => f.split("/").pop().replace(".ts", "")));
let failed = false;
-for (const tag of catalogues) {
+for (const tag of catalogs) {
if (!registered.has(tag)) {
failed = true;
console.log(`!! ${tag}.ts exists but is not in UI_LANGUAGES — the language is never offered\n`);
}
}
for (const tag of registered) {
- if (tag !== "en" && !catalogues.has(tag)) {
+ if (tag !== "en" && !catalogs.has(tag)) {
failed = true;
console.log(`!! UI_LANGUAGES offers ${tag} but there is no ${tag}.ts — it would fall back to English\n`);
}
diff --git a/scripts/i18n-literals.mjs b/scripts/i18n-literals.mjs
index 0f7cb98..e6df303 100644
--- a/scripts/i18n-literals.mjs
+++ b/scripts/i18n-literals.mjs
@@ -11,11 +11,11 @@
* A string reaches a reader translated if either is true:
*
* 1. it is wrapped where it is written -- t(), tc(), tNode(), plural()
- * 2. it is a catalogue key, translated somewhere else
+ * 2. it is a catalog key, translated somewhere else
*
* The second case is a real convention here, not a loophole: constant tables
* hold English and the render site calls `t(s.label)`. What this refuses is a
- * string that is neither -- one no catalogue has a key for, which therefore
+ * string that is neither -- one no catalog has a key for, which therefore
* cannot be translated at all, however many languages ship.
*/
/*
@@ -56,7 +56,7 @@ const EQUALITY = new Set([
/*
* Product names, example addresses and URL scaffolding. These reach t() and
- * are deliberately absent from every catalogue -- translating "ihasmail" or
+ * are deliberately absent from every catalog -- translating "ihasmail" or
* "name@example.com" would be a bug, not a feature -- so they would otherwise
* be reported for ever.
*/
@@ -81,7 +81,7 @@ const found = [];
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
/*
- * `strict` withdraws the catalogue-key exemption. It exists for English held
+ * `strict` withdraws the catalog-key exemption. It exists for English held
* in a constant and translated where it renders; a literal written straight
* into a JSX attribute has no later render site to be translated at -- no
* component here passes its props through t() -- so being a key only means
@@ -119,7 +119,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
/*
* English assembled around values: `aria-label={`Remove ${email}`}`.
*
- * The literal cannot be a catalogue key as written, so whether it is a key is
+ * The literal cannot be a catalog key as written, so whether it is a key is
* not asked. Neither is looksLikeUi, which reads the opening of a sentence:
* `${name} — shared by ${owner}` opens with a value and its words come after.
* Any run of letters between the values counts. The only template literals
@@ -151,7 +151,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
&& n.expression.expression.getText(src) === "toast" && TOASTS.has(n.expression.name.text)) {
const a0 = n.arguments[0];
if (a0 && ts.isStringLiteral(a0) && !wrapped.has(a0)) report(a0, a0.text);
- /* A template literal cannot be a catalogue key at all, so it is always a find. */
+ /* A template literal cannot be a catalog key at all, so it is always a find. */
if (a0 && ts.isTemplateExpression(a0)) report(a0, a0.head.text + "{}");
}
ts.forEachChild(n, visit);
@@ -160,11 +160,11 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
}
if (!found.length) {
- console.log("i18n literals: none -- every user-visible string is wrapped or has a catalogue key");
+ console.log("i18n literals: none -- every user-visible string is wrapped or has a catalog key");
process.exit(0);
}
-console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalogue can translate:\n`);
+console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalog can translate:\n`);
for (const f of found) console.log(` ${f.file}:${f.line}\n ${JSON.stringify(f.text)}`);
console.log("\nWrap them in t() / plural(), or -- for a label held in a constant and");
-console.log("translated where it renders -- make sure the English is a catalogue key.");
+console.log("translated where it renders -- make sure the English is a catalog key.");
process.exit(process.argv.includes("--check") ? 1 : 0);
diff --git a/scripts/i18n-strings.mjs b/scripts/i18n-strings.mjs
index 310a2ad..6b7ceee 100644
--- a/scripts/i18n-strings.mjs
+++ b/scripts/i18n-strings.mjs
@@ -1,10 +1,10 @@
#!/usr/bin/env node
/*
- * Every source string a catalogue needs, straight out of the calls.
+ * Every source string a catalog needs, straight out of the calls.
*
- * The English text is the key, so the catalogue's keys are not a list somebody
+ * The English text is the key, so the catalog's keys are not a list somebody
* maintains -- they are whatever t(), tNode() and plural() are actually asked
- * for. Reading them from the code means a catalogue can never drift out of
+ * for. Reading them from the code means a catalog can never drift out of
* step with the app in the one direction that matters: a key that no longer
* exists is dead weight, but a call with no key is an untranslated string
* nobody noticed.
diff --git a/server/src/account.test.ts b/server/src/account.test.ts
index 7f6df7a..d031904 100644
--- a/server/src/account.test.ts
+++ b/server/src/account.test.ts
@@ -156,7 +156,7 @@ test("with 2FA on, a password change needs the current code too", async () => {
test("2FA is switched off with the password and a current code", async () => {
const state = await call("/api/account/security");
assert.equal(state.body.otpEnabled, true);
- // The enrolment secret is known only to the client, so disabling uses a code
+ // The enrollment secret is known only to the client, so disabling uses a code
// from the authenticator - here, the one the mock stored.
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
const params = parseOtpauthUrl(stored!);
diff --git a/server/src/account.ts b/server/src/account.ts
index 16d0c34..bcaa64e 100644
--- a/server/src/account.ts
+++ b/server/src/account.ts
@@ -169,10 +169,10 @@ export async function revokeAppPassword(ctx: Ctx, id: string): Promise