Use American English spelling throughout
This commit is contained in:
@@ -7,7 +7,7 @@ on:
|
|||||||
# Without this there is no way to re-run a check that never started: a run
|
# 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
|
# 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")
|
# 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
|
# 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.
|
# change, without pushing an empty commit to move it.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# `ghcr.io/coffey-labs/ihasmail:latest` for a long time, and nothing ever
|
# `ghcr.io/coffey-labs/ihasmail:latest` for a long time, and nothing ever
|
||||||
# pushed it: `docker pull` answered `denied`, because the package did not
|
# pushed it: `docker pull` answered `denied`, because the package did not
|
||||||
# exist. This is the workflow that makes those instructions true. It is also
|
# 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.
|
# 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
|
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
|
||||||
@@ -44,7 +44,7 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
|
# 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
|
# 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
|
# runner died. `ref` also allows publishing an image for a tag that predates
|
||||||
# this workflow, which is how the first one gets built.
|
# this workflow, which is how the first one gets built.
|
||||||
|
|||||||
@@ -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
|
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
|
sunken dark surface #001f28 (below base03) and the raised light surface
|
||||||
#fffdf6 (above base3), neither of which Solarized publishes, plus the two
|
#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
|
## Everforest — sainnhe/everforest, MIT (palette.md), medium contrast
|
||||||
### Dark
|
### Dark
|
||||||
bg_dim #232a2e · bg0 #2d353b · bg1 #343f44 · bg3 #475258
|
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
|
red #e67e80 · orange #e69875 · yellow #dbbc7f · green #a7c080 · aqua #83c092
|
||||||
blue #7fbbb3 · purple #d699b6
|
blue #7fbbb3 · purple #d699b6
|
||||||
|
|
||||||
### Light
|
### Light
|
||||||
bg_dim #efebd4 · bg0 #fdf6e3 · bg3 #e6e2cc · bg5 #bdc3af
|
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
|
red #f85552 · orange #f57d26 · yellow #dfa000 · green #8da101 · aqua #35a77c
|
||||||
blue #3a94c5 · purple #df69ba
|
blue #3a94c5 · purple #df69ba
|
||||||
|
|
||||||
Light uses bg_dim as the page and bg0 as the raised surface, so the card the
|
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)
|
## Kanagawa — rebelot/kanagawa.nvim, MIT (lua/kanagawa/colors.lua)
|
||||||
### Wave (dark)
|
### Wave (dark)
|
||||||
@@ -118,7 +118,7 @@ lotusGreen #6f894e · lotusYellow #77713f · lotusPink #b35b79
|
|||||||
## Ayu — ayu-theme/ayu-colors, MIT (themes/dark.yaml, themes/light.yaml)
|
## 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
|
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
|
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.
|
MIT-licensed ayu-theme/vscode-ayu build.
|
||||||
|
|
||||||
### Dark
|
### 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)
|
indigo #55B4D4 · blue #22A4E6 · purple #A37ACC · accent #F29718 (vscode-ayu)
|
||||||
|
|
||||||
## Primer — primer/primitives, MIT (src/tokens/base/color/{dark,light})
|
## 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.
|
and the Invertocat are trademarks, and nothing here is endorsed by them.
|
||||||
|
|
||||||
### Dark
|
### Dark
|
||||||
|
|||||||
+7
-7
@@ -74,11 +74,11 @@ failing, so an untranslated string is invisible until somebody reading that
|
|||||||
language finds it.
|
language finds it.
|
||||||
|
|
||||||
**Any change that adds or alters a user-visible string adds work in all nine
|
**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,
|
count before and after — and say so just as explicitly when a change adds none,
|
||||||
so it is never left to be inferred.
|
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
|
`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" })
|
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
|
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
|
in all nine languages. Nothing errors. The only signal is the fallback count
|
||||||
going up, so read it:
|
going up, so read it:
|
||||||
|
|
||||||
```sh
|
```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
|
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
|
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.
|
- `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`,
|
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
||||||
@@ -179,10 +179,10 @@ RFC 8984's.
|
|||||||
| `MOCK_EDITION=enterprise` | Reports Enterprise, which Tenants needs |
|
| `MOCK_EDITION=enterprise` | Reports Enterprise, which Tenants needs |
|
||||||
|
|
||||||
It tracks the current Stalwart release rather than 0.16 in general, and each
|
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
|
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
|
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
|
#### Version numbers
|
||||||
|
|
||||||
|
|||||||
+58
-58
@@ -13,7 +13,7 @@ questions:
|
|||||||
| [docs.ihasmail.org](https://docs.ihasmail.org) | How to install, configure and drive each of these |
|
| [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
|
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,
|
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
|
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`
|
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
|
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,
|
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
|
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
|
actually happen *in the folder it is happening in*: "Delete forever" out of
|
||||||
Deleted Items, "Not spam" inside Junk Mail. Where an action
|
Deleted Items, "Not spam" inside Junk Mail. Where an action
|
||||||
is meaningless there — archiving out of the archive, calling your own drafts
|
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
|
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
|
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
|
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
|
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.
|
more attempt. A drag that is merely more sideways than not stays a scroll.
|
||||||
|
|
||||||
## The message list
|
## 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
|
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.
|
height follows density and the one- or two-line layout.
|
||||||
- **Infinite scroll** with server-side paging, 50 at a time by default.
|
- **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
|
## 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).
|
- Create, rename, create a subfolder, delete (with or without its mail).
|
||||||
- **Drag a folder onto another** to reparent it. Folders with a server role
|
- **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
|
unsubscribed folder still exists and still receives; it is just out of the
|
||||||
way. Inbox cannot be hidden.
|
way. Inbox cannot be hidden.
|
||||||
- **Mark all as read**, optionally including subfolders.
|
- **Mark all as read**, optionally including subfolders.
|
||||||
- **Folder colours**, per mailbox id.
|
- **Folder colors**, per mailbox id.
|
||||||
- **Unread counts** per folder, live.
|
- **Unread counts** per folder, live.
|
||||||
- **Storage quota** bar under the tree where the server reports one.
|
- **Storage quota** bar under the tree where the server reports one.
|
||||||
- Rights are respected per folder: rename, delete, create-child and share each
|
- 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
|
- 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
|
missing*, rather than drawing an empty folder — a stale link should not read
|
||||||
as a folder that emptied itself.
|
as a folder that emptied itself.
|
||||||
|
|
||||||
## Labels
|
## 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,
|
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
|
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
|
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
|
## 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
|
cannot reach the app. DOMPurify strips scripts, event handlers, forms and
|
||||||
anything that could navigate the top window.
|
anything that could navigate the top window.
|
||||||
- **Remote images blocked by default**, with a banner offering *Show images* or
|
- **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
|
since the filter applies policy ihasmail cannot see. Mail that arrived without
|
||||||
these headers shows nothing.
|
these headers shows nothing.
|
||||||
- **Message body theming** is off by default — sender HTML is left exactly as it
|
- **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
|
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
|
`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
|
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
|
*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
|
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
|
label stays readable on it. Nothing the sender wrote is removed, so the
|
||||||
switch is reversible, and print is unaffected either way.
|
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
|
- **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
|
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
|
- **vCard attachments** render a card offering to add the person to an address
|
||||||
book.
|
book.
|
||||||
- **Right-click anyone named** in the message — From, To, Cc, Bcc or Reply-To —
|
- **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
|
## Composing
|
||||||
|
|
||||||
**Multiple composers at once**, floating in a dock at the bottom right, each
|
**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,
|
font size, alignment, bulleted and numbered lists, indent/outdent, blockquote,
|
||||||
code block, links (`Ctrl+K`), inline images, an emoji picker, and remove
|
code block, links (`Ctrl+K`), inline images, an emoji picker, and remove
|
||||||
formatting. Tab and Shift+Tab indent inside the body.
|
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
|
- **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
|
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
|
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
|
- **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
|
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
|
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
|
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
|
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.
|
date and time, bounded by the maximum delay the server advertises.
|
||||||
|
|
||||||
> If Stalwart's `futureRelease` is not configured, a "scheduled" message is sent
|
> If Stalwart's `futureRelease` is not configured, a "scheduled" message is sent
|
||||||
@@ -536,14 +536,14 @@ are settings.
|
|||||||
|
|
||||||
The sidebar keeps three groups apart:
|
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.
|
- **Shared with me** — other people's, once added.
|
||||||
- **Available to add** — shared with you but not yet added, with a plus beside
|
- **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
|
each. An unadded calendar draws nothing. This is deliberate: the server
|
||||||
reports every collection in an account you can reach, whether or not anyone
|
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.
|
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
|
right-click one of someone else's to remove it from your view, which changes
|
||||||
nothing for anybody else.
|
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
|
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.
|
filed rather than scheduled: no invitations go out to anyone named in them.
|
||||||
- **Re-importing updates rather than duplicates**, as a contacts import does.
|
- **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.
|
wins -- so a corrected export corrects what the first attempt got wrong.
|
||||||
|
|
||||||
Two things are deliberately left alone: **who accepted**, and **edits to a
|
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
|
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
|
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* --
|
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.
|
sends the update from the event itself.
|
||||||
- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list.
|
- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list.
|
||||||
Added in Settings › Calendar & contacts, read-only, and shown beside your own
|
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
|
**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,
|
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
|
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
|
than being special-cased: the virtual calendar reports no write rights, so
|
||||||
every control that asks before offering Edit or Delete already declines. The
|
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
|
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
|
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),
|
The editor covers title, start and end (all-day or timed, with a time zone),
|
||||||
calendar, location, meeting link, guests, description, reminders, repeat,
|
calendar, location, meeting link, guests, description, reminders, repeat,
|
||||||
status (confirmed / tentative / cancelled), show-as (busy / free), visibility
|
status (confirmed / tentative / canceled), show-as (busy / free), visibility
|
||||||
(default / private / secret), category and colour.
|
(default / private / secret), category and color.
|
||||||
|
|
||||||
- **Recurrence** — none, daily, weekly, weekdays, monthly, yearly, or a custom
|
- **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.
|
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.
|
- **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
|
Calendar, assigned from the editor or the context menu, and stored as
|
||||||
JSCalendar `categories` so other clients see them. (The per-event colour
|
JSCalendar `categories` so other clients see them. (The per-event color
|
||||||
picker that predated them is gone; a colour comes from the category, or the
|
picker that predated them is gone; a color comes from the category, or the
|
||||||
calendar.)
|
calendar.)
|
||||||
- **Duplicate** an event from the context menu.
|
- **Duplicate** an event from the context menu.
|
||||||
- **Create event…** from a message, in its context menu and its ⋮ menu (and,
|
- **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
|
## Attendees, invitations and free/busy
|
||||||
|
|
||||||
Invitations go out as iTIP when guests are added, replies come back and are
|
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.
|
name or address with the same autocomplete the composer uses.
|
||||||
|
|
||||||
Where the server implements `Principal/getAvailability`, the event editor grows
|
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.
|
afterwards is what the server confirmed rather than what was asked for.
|
||||||
- **Letter index** down the list, with `#` for everything that does not start
|
- **Letter index** down the list, with `#` for everything that does not start
|
||||||
with a letter.
|
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),
|
- **vCard import** through `ContactCard/parse` (a file of any number of cards),
|
||||||
and **export** of one card or the whole book as `.vcf`.
|
and **export** of one card or the whole book as `.vcf`.
|
||||||
- **LDIF import**, for address books coming from SOGo, Thunderbird or an LDAP
|
- **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:
|
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
|
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
|
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
|
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
|
control takes either format and decides by what is in the file, not by what it
|
||||||
is called.
|
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
|
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,
|
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 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
|
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
|
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."*
|
again and counted: *"3 of them look like contacts you already had."*
|
||||||
|
|
||||||
[ldif-schema]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema
|
[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
|
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,
|
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
|
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
|
backed up with the mail store, because they *are* in the mail store. ihasmail
|
||||||
still stores nothing of its own.
|
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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Filters & rules** | The visual builder and raw Sieve editor |
|
||||||
| **Out of office** | Vacation response |
|
| **Out of office** | Vacation response |
|
||||||
| **Folders** | Create, rename, colour, subscribe |
|
| **Folders** | Create, rename, color, subscribe |
|
||||||
| **Labels** | Keyword, display name, colour |
|
| **Labels** | Keyword, display name, color |
|
||||||
| **Templates** | Named subject + body |
|
| **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 |
|
| **Notifications** | In-tab notifications, notify-when-closed (Web Push), sound |
|
||||||
| **Security & sessions** | Password, two-factor state, app passwords, active webmail sessions |
|
| **Security & sessions** | Password, two-factor state, app passwords, active webmail sessions |
|
||||||
| **Keyboard shortcuts** | The full list, grouped |
|
| **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
|
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
|
menu becomes something people hunt through. Security & sessions is credentials
|
||||||
and access: password, two-factor state, app passwords, live sessions. Privacy &
|
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
|
loads, what leaks, and what asks before it happens. These had been spread
|
||||||
through General, which had grown five unrelated headings — remote images filed
|
through General, which had grown five unrelated headings — remote images filed
|
||||||
under "Reading", the read-receipt policy under "Composing", the undo-send window
|
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 |
|
| Deutsch · Español · Français · Nederlands · Português (Brasil) | Beta |
|
||||||
| Русский · Українська · 简体中文 · 日本語 | Beta |
|
| Русский · Українська · 简体中文 · 日本語 | Beta |
|
||||||
|
|
||||||
**All nine translations are marked Beta, and the label is not modesty.**
|
**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
|
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 —
|
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
|
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:
|
Two things follow from the design rather than the translation:
|
||||||
|
|
||||||
- **A missing entry renders its English source.** So deleting a bad line is a
|
- **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,
|
- **Plurals are asked for, never assumed.** `Intl.PluralRules` decides the form,
|
||||||
so Russian and Ukrainian get their three (1 письмо, 2–4 письма, 5+ писем) and
|
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
|
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
|
page that is already in the reader's language — and accepting that offer is
|
||||||
what rewrites the DOM underneath React.
|
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
|
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
|
language it is not, which is worse than not offering it: it stops a browser
|
||||||
offering to translate a page the reader cannot read.
|
offering to translate a page the reader cannot read.
|
||||||
@@ -1061,28 +1061,28 @@ at two.
|
|||||||
| **Ayu** | |
|
| **Ayu** | |
|
||||||
| **Kanagawa** | Wave, with Lotus as its light half |
|
| **Kanagawa** | Wave, with Lotus as its light half |
|
||||||
| **Everforest** | The medium-contrast variant of each side |
|
| **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
|
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
|
The ten borrowed palettes are the work of their own projects and are used
|
||||||
under the MIT license — 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
|
are used, taken from each project's own repository; the values as fetched are
|
||||||
recorded in `.palette-sources/palettes-upstream.md`.
|
recorded in `.palette-sources/palettes-upstream.md`.
|
||||||
|
|
||||||
**The shades between those values are derived, and every one is checked.**
|
**The shades between those values are derived, and every one is checked.**
|
||||||
ihasmail needs about thirty tokens and these projects publish between twelve
|
ihasmail needs about thirty tokens and these projects publish between twelve
|
||||||
and twenty, so the tiers in between are computed by
|
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
|
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
|
a light one so the hue survives. The script refuses to write a palette that
|
||||||
would not pass.
|
would not pass.
|
||||||
|
|
||||||
That check is not a formality. **Twenty-one of the twenty-two palette halves
|
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
|
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
|
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.
|
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
|
## 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
|
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
|
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
|
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
|
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
|
no permissions, and the JMAP proxy refuses registry methods beyond the account's
|
||||||
own. The menu still shows **Administration** to an administrator in that
|
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
|
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.
|
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
|
worker genuinely cannot reach is anything a *tab* holds in memory — and the
|
||||||
API asks for none of it.
|
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
|
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
|
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
|
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 |
|
| Administration | unavailable | available, if the role allows it |
|
||||||
|
|
||||||
Local storage is gated on that answer for **reads** as well as writes — a
|
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
|
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
|
cache and recent addresses and tears down the push subscription, whichever
|
||||||
answer was given.
|
answer was given.
|
||||||
@@ -1614,10 +1614,10 @@ This is S/MIME only, and it stops at reading: nothing here signs, encrypts or
|
|||||||
decrypts anything.
|
decrypts anything.
|
||||||
|
|
||||||
**What it checks.** For a `multipart/signed` message carrying a PKCS#7
|
**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
|
to CRLF — are hashed and compared against the `messageDigest` the signature
|
||||||
covers, and the signature over the signed attributes is verified with WebCrypto
|
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
|
ECDSA over P-256, P-384 and P-521 are supported, with SHA-256, SHA-384 or
|
||||||
SHA-512.
|
SHA-512.
|
||||||
|
|
||||||
@@ -1634,12 +1634,12 @@ authority:
|
|||||||
|
|
||||||
| what happened | what you see |
|
| 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 |
|
| 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 |
|
| **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 |
|
| 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 |
|
| 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
|
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
|
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
|
`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
|
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
|
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
|
persisting them is best-effort, so a read-only `/data` costs one warning at the
|
||||||
|
|||||||
+12
-12
@@ -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.
|
- **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`.
|
- **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.
|
- **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.**
|
- **`x:DkimSignature/query` accepts a `domainId` filter.**
|
||||||
- **`catchAllAddress` wants a whole address.** A bare local part is refused with `invalidPatch`, *"Invalid email address"*.
|
- **`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.
|
- **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.
|
- **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 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:
|
- **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.
|
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.
|
- **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 `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalogue key, although no component here runs its props through `t()`, so 19 strings with translations in every catalogue (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogues. English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``, was the last gap. It can't be a catalogue key as written. Since 2026-09-14 the check flags any template literal in one of these positions that has words between its values, and the twelve that existed are now keys with placeholders. They were the quota bar, the address menu, a folder's unread count, the recipient chips, the contact editor's title, shared calendars and address books, the date and time fields, the attachment fallback name, and the free/busy bar. That bar showed the raw JMAP value (`confirmed`) in every language.
|
- **`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 catalogs 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 catalog 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 catalog 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 `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalog key, although no component here runs its props through `t()`, so 19 strings with translations in every catalog (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogs. English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``, was the last gap. It can't be a catalog key as written. Since 2026-09-14 the check flags any template literal in one of these positions that has words between its values, and the twelve that existed are now keys with placeholders. They were the quota bar, the address menu, a folder's unread count, the recipient chips, the contact editor's title, shared calendars and address books, the date and time fields, the attachment fallback name, and the free/busy bar. That bar showed the raw JMAP value (`confirmed`) in every language.
|
||||||
|
|
||||||
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
||||||
|
|
||||||
@@ -128,22 +128,22 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
|||||||
- **A create answers with the id alone**, no `createdAt`, so anything that reads the date back out of the create response gets `undefined`. **Patching `key` on an existing entry is allowed**, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keeps `createdAt` meaning what it says.
|
- **A create answers with the id alone**, no `createdAt`, so anything that reads the date back out of the create response gets `undefined`. **Patching `key` on an existing entry is allowed**, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keeps `createdAt` meaning what it says.
|
||||||
- **`expiresAt` is the registry's own field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
|
- **`expiresAt` is the registry's own field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
|
||||||
|
|
||||||
- **Signature checking is done here, and its trust model is deliberately small.** Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does *not* do, because the gap is a design choice rather than an omission. **No chain of trust is validated** — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is grey rather than green, and why a changed signer never overwrites the pin. Verified against real `openssl smime -sign` output rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be.
|
- **Signature checking is done here, and its trust model is deliberately small.** Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does *not* do, because the gap is a design choice rather than an omission. **No chain of trust is validated** — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is gray rather than green, and why a changed signer never overwrites the pin. Verified against real `openssl smime -sign` output rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be.
|
||||||
- **OpenPGP signatures cannot be checked at all, for a reason that is not effort.** A PGP signature carries no key, so verifying one needs the sender's public key in advance, and there is nowhere to get it: `x:PublicKey` holds the *account's own* keys, not correspondents'. Fetching from a keyserver or via WKD would tell a third party who you correspond with each time you opened a message — the same leak the image proxy exists to close — so it is not done. Such a message says so by name rather than failing as an unknown format, and it says *could not check* rather than *did not check out*, which is a distinction worth keeping: one is ignorance and the other is an accusation.
|
- **OpenPGP signatures cannot be checked at all, for a reason that is not effort.** A PGP signature carries no key, so verifying one needs the sender's public key in advance, and there is nowhere to get it: `x:PublicKey` holds the *account's own* keys, not correspondents'. Fetching from a keyserver or via WKD would tell a third party who you correspond with each time you opened a message — the same leak the image proxy exists to close — so it is not done. Such a message says so by name rather than failing as an unknown format, and it says *could not check* rather than *did not check out*, which is a distinction worth keeping: one is ignorance and the other is an accusation.
|
||||||
- **Two signature shapes are declined rather than attempted.** SHA-1 signatures are refused outright — one nobody can forge in practice today is still not one to put a tick beside. RSA-PSS is declined because the salt length lives in parameters ihasmail does not read, and guessing wrong would report a perfectly good signature as *bad*, which is a far worse thing to say than "cannot check". Both are shown as uncheckable, not as broken.
|
- **Two signature shapes are declined rather than attempted.** SHA-1 signatures are refused outright — one nobody can forge in practice today is still not one to put a tick beside. RSA-PSS is declined because the salt length lives in parameters ihasmail does not read, and guessing wrong would report a perfectly good signature as *bad*, which is a far worse thing to say than "cannot check". Both are shown as uncheckable, not as broken.
|
||||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
||||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
||||||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
||||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognize ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||||
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
|
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
|
||||||
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
||||||
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honors a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, canceled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a canceled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
||||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. Since 0.16.22 the same event read by its *stored* id answers `baseEventId: null` rather than its own id, which changes nothing here: a one-off read through the synthetic id an expanded query gave it still carries a base.
|
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Canceling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. Since 0.16.22 the same event read by its *stored* id answers `baseEventId: null` rather than its own id, which changes nothing here: a one-off read through the synthetic id an expanded query gave it still carries a base.
|
||||||
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email` — **no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
|
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email` — **no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
|
||||||
|
|
||||||
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behaviour and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behavior and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
||||||
|
|
||||||
- **A synthetic id was only true until the next write, through 0.16.20. Fixed in 0.16.21.** Stalwart's expanded-occurrence ids used to encode a position in the series, so writing a `recurrenceOverrides` entry renumbered them. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier, so an id cached across a write silently pointed at another date and a delete meant for one occurrence removed a different one. The failure was never a `notFound` a client would notice; it was a confident answer about the wrong day. **0.16.21 identifies an occurrence by its recurrence id, and confirming that was the point of re-running rather than reading the diff. Confirmed live on 0.16.21 (2026-09-06)**: the same shape of test — five weekly occurrences expanded, the third retitled through its own synthetic id, all five original ids re-read — left every id on its own date, with none renumbered and none `notFound`. A second override written through the interface behaved the same way. The defence stays regardless: ihasmail still never mutates an occurrence by an id it is holding, and `updateEvent` and `destroyEvent` still re-resolve by `recurrenceId` immediately before acting, because a date can still leave a series and because the client supports 0.16 as a whole rather than only its newest release. The mock follows the new behaviour, and the test that pinned the old renumbering now pins the stability instead — rewritten rather than deleted, so the reversal stays on the record.
|
- **A synthetic id was only true until the next write, through 0.16.20. Fixed in 0.16.21.** Stalwart's expanded-occurrence ids used to encode a position in the series, so writing a `recurrenceOverrides` entry renumbered them. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier, so an id cached across a write silently pointed at another date and a delete meant for one occurrence removed a different one. The failure was never a `notFound` a client would notice; it was a confident answer about the wrong day. **0.16.21 identifies an occurrence by its recurrence id, and confirming that was the point of re-running rather than reading the diff. Confirmed live on 0.16.21 (2026-09-06)**: the same shape of test — five weekly occurrences expanded, the third retitled through its own synthetic id, all five original ids re-read — left every id on its own date, with none renumbered and none `notFound`. A second override written through the interface behaved the same way. The defense stays regardless: ihasmail still never mutates an occurrence by an id it is holding, and `updateEvent` and `destroyEvent` still re-resolve by `recurrenceId` immediately before acting, because a date can still leave a series and because the client supports 0.16 as a whole rather than only its newest release. The mock follows the new behavior, and the test that pinned the old renumbering now pins the stability instead — rewritten rather than deleted, so the reversal stays on the record.
|
||||||
|
|
||||||
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
|
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
ihasmail is licensed under the AGPL-3.0; see LICENSE. This file records work by
|
ihasmail is licensed under the AGPL-3.0; see LICENSE. This file records work by
|
||||||
other people that ships inside it and the terms it comes under.
|
other people that ships inside it and the terms it comes under.
|
||||||
|
|
||||||
## Colour palettes
|
## Color palettes
|
||||||
|
|
||||||
Ten of the palettes offered in Settings › Appearance are the work of their own
|
Ten of the palettes offered in Settings › Appearance are the work of their own
|
||||||
projects and are used under the MIT license. Only the published colour values
|
projects and are used under the MIT license. Only the published color values
|
||||||
are used — no code, and nothing from anyone else's reimplementation of them.
|
are used — no code, and nothing from anyone else's reimplementation of them.
|
||||||
The values as fetched from each project are recorded in
|
The values as fetched from each project are recorded in
|
||||||
`.palette-sources/palettes-upstream.md`, and the shades between them are
|
`.palette-sources/palettes-upstream.md`, and the shades between them are
|
||||||
@@ -50,7 +50,7 @@ share one set of accent values by design.
|
|||||||
### Ayu
|
### Ayu
|
||||||
|
|
||||||
Copyright (c) Konstantin Pschera — https://github.com/ayu-theme/ayu-colors
|
Copyright (c) Konstantin Pschera — https://github.com/ayu-theme/ayu-colors
|
||||||
Licensed under the MIT license. The two signature accent colours come from the
|
Licensed under the MIT license. The two signature accent colors come from the
|
||||||
same author's ayu-theme/vscode-ayu, also MIT.
|
same author's ayu-theme/vscode-ayu, also MIT.
|
||||||
|
|
||||||
### Kanagawa
|
### Kanagawa
|
||||||
@@ -68,7 +68,7 @@ the one used here.
|
|||||||
### Primer
|
### Primer
|
||||||
|
|
||||||
Copyright (c) GitHub, Inc. — https://github.com/primer/primitives
|
Copyright (c) GitHub, Inc. — https://github.com/primer/primitives
|
||||||
Licensed under the MIT license, which covers the colour values. "GitHub" and
|
Licensed under the MIT license, which covers the color values. "GitHub" and
|
||||||
the Invertocat logo are trademarks of GitHub, Inc.; this palette is named
|
the Invertocat logo are trademarks of GitHub, Inc.; this palette is named
|
||||||
"Primer" after the design system and is neither affiliated with nor endorsed
|
"Primer" after the design system and is neither affiliated with nor endorsed
|
||||||
by GitHub.
|
by GitHub.
|
||||||
|
|||||||
+3
-3
@@ -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.
|
- **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)
|
- 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.
|
- **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.
|
- **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.
|
- **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.
|
**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.
|
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.
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const waitFor = async (jsExpr, what, ms = 15000) => {
|
|||||||
* capture — twice, silently, producing a "light" screenshot of the dark theme.
|
* capture — twice, silently, producing a "light" screenshot of the dark theme.
|
||||||
* A MutationObserver puts it back faster than anything can take it away.
|
* 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"
|
const themeTest = (want) => want === "light"
|
||||||
? "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) > 200"
|
? "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) > 200"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function baseUrlOf(basePath) {
|
|||||||
*
|
*
|
||||||
* The comparison is deliberately not `startsWith(base)`: that would let
|
* The comparison is deliberately not `startsWith(base)`: that would let
|
||||||
* `/mailbox` in under a `/mail` mount and serve it the app shell, which is
|
* `/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) {
|
export function stripBasePath(basePath, pathname) {
|
||||||
const base = normalizeBasePath(basePath);
|
const base = normalizeBasePath(basePath);
|
||||||
|
|||||||
+22
-22
@@ -2,15 +2,15 @@
|
|||||||
"""
|
"""
|
||||||
Generate the palette CSS blocks in web/src/styles/app.css.
|
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
|
are recorded in .palette-sources/palettes-upstream.md. What this script adds is
|
||||||
the *derivation*: ihasmail needs thirty-odd tokens and these projects publish
|
the *derivation*: ihasmail needs thirty-odd tokens and these projects publish
|
||||||
between twelve and twenty, so the tiers in between are computed rather than
|
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
|
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 --
|
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
|
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
|
being true; here it is arithmetic, and the script fails loudly if a token it
|
||||||
emitted would not pass.
|
emitted would not pass.
|
||||||
@@ -30,7 +30,7 @@ BEGIN = "/* === generated palettes: begin === */"
|
|||||||
END = "/* === generated palettes: end === */"
|
END = "/* === generated palettes: end === */"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- colour maths
|
# ---------------------------------------------------------------- color maths
|
||||||
|
|
||||||
def parse(hex_: str) -> tuple[float, float, float]:
|
def parse(hex_: str) -> tuple[float, float, float]:
|
||||||
h = hex_.lstrip("#")
|
h = hex_.lstrip("#")
|
||||||
@@ -66,18 +66,18 @@ def rgba(hex_: str, alpha: float) -> str:
|
|||||||
return f"rgba({r}, {g}, {b}, {alpha})"
|
return f"rgba({r}, {g}, {b}, {alpha})"
|
||||||
|
|
||||||
|
|
||||||
def toward_contrast(colour: str, bg: str, target: float, dark_ui: bool) -> str:
|
def toward_contrast(color: str, bg: str, target: float, dark_ui: bool) -> str:
|
||||||
"""Nudge `colour` away from `bg` until it clears `target`.
|
"""Nudge `color` away from `bg` until it clears `target`.
|
||||||
|
|
||||||
Towards white on a dark background and towards black on a light one, so a
|
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 grey.
|
lifted tier keeps its hue instead of washing out to gray.
|
||||||
"""
|
"""
|
||||||
if contrast(colour, bg) >= target:
|
if contrast(color, bg) >= target:
|
||||||
return colour
|
return color
|
||||||
anchor = "#ffffff" if dark_ui else "#000000"
|
anchor = "#ffffff" if dark_ui else "#000000"
|
||||||
best = colour
|
best = color
|
||||||
for i in range(1, 101):
|
for i in range(1, 101):
|
||||||
candidate = mix(colour, anchor, i / 100)
|
candidate = mix(color, anchor, i / 100)
|
||||||
best = candidate
|
best = candidate
|
||||||
if contrast(candidate, bg) >= target:
|
if contrast(candidate, bg) >= target:
|
||||||
return candidate
|
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 --
|
# 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
|
# 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.
|
# here, which is why it appears in LIGHT_ONLY.
|
||||||
LIGHT_ONLY = {"ihasmail"}
|
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"]
|
bg, fg = src["bg"], src["fg"]
|
||||||
notes: list[str] = []
|
notes: list[str] = []
|
||||||
|
|
||||||
def lift(name: str, colour: str, target: float) -> str:
|
def lift(name: str, color: str, target: float) -> str:
|
||||||
out = toward_contrast(colour, bg, target, dark)
|
out = toward_contrast(color, bg, target, dark)
|
||||||
if out != colour:
|
if out != color:
|
||||||
notes.append(f"{name} {colour} -> {out} ({contrast(colour, bg):.2f} -> {contrast(out, bg):.2f})")
|
notes.append(f"{name} {color} -> {out} ({contrast(color, bg):.2f} -> {contrast(out, bg):.2f})")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Body text is lifted like every other text tone rather than exempted.
|
# 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.
|
# 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
|
# 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
|
# reader can name, and the shift is recorded in the header of the
|
||||||
# generated block like every other one.
|
# generated block like every other one.
|
||||||
fg = lift("fg", fg, TEXT_ON_BG["fg"])
|
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.",
|
" * 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",
|
" * 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",
|
" * 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 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.",
|
" * is why this is arithmetic rather than a hand-written block.",
|
||||||
" */",
|
" */",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/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.
|
* 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.
|
* as an untranslated word on screen, which somebody will eventually notice.
|
||||||
*
|
*
|
||||||
* A *stale* key -- one whose English no longer exists, usually because it was
|
* 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
|
* 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.
|
* 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.
|
* 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
|
* 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
|
* -- widening it would count every CSS class and JMAP method name as an
|
||||||
* untranslated string.
|
* untranslated string.
|
||||||
*
|
*
|
||||||
* `seen` is every string literal in the source, and answers only "is this
|
* `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
|
* net: a key reaches t() as a variable often enough that a strict set reports
|
||||||
* mostly false alarms.
|
* 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); }
|
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 --
|
* 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.
|
* 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);
|
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)) {
|
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
|
||||||
const fn = n.expression.text, a0 = n.arguments[0];
|
const fn = n.expression.text, a0 = n.arguments[0];
|
||||||
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
|
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
|
// control character tc() uses. Without this the contextual entries all
|
||||||
// looked stale, which is the checker's own false alarm rather than a
|
// 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])) {
|
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
|
// Only the contextual key is required. The plain one is tc()'s
|
||||||
// fallback, not a second obligation -- asking for both would report
|
// 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
|
* A catalog 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
|
* 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
|
* 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
|
* 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
|
* 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 languagesSrc = readFileSync("web/src/lib/languages.ts", "utf8");
|
||||||
const registered = new Set([...languagesSrc.matchAll(/tag:\s*"([\w-]+)"/g)].map((m) => m[1]));
|
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;
|
let failed = false;
|
||||||
for (const tag of catalogues) {
|
for (const tag of catalogs) {
|
||||||
if (!registered.has(tag)) {
|
if (!registered.has(tag)) {
|
||||||
failed = true;
|
failed = true;
|
||||||
console.log(`!! ${tag}.ts exists but is not in UI_LANGUAGES — the language is never offered\n`);
|
console.log(`!! ${tag}.ts exists but is not in UI_LANGUAGES — the language is never offered\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const tag of registered) {
|
for (const tag of registered) {
|
||||||
if (tag !== "en" && !catalogues.has(tag)) {
|
if (tag !== "en" && !catalogs.has(tag)) {
|
||||||
failed = true;
|
failed = true;
|
||||||
console.log(`!! UI_LANGUAGES offers ${tag} but there is no ${tag}.ts — it would fall back to English\n`);
|
console.log(`!! UI_LANGUAGES offers ${tag} but there is no ${tag}.ts — it would fall back to English\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
* A string reaches a reader translated if either is true:
|
* A string reaches a reader translated if either is true:
|
||||||
*
|
*
|
||||||
* 1. it is wrapped where it is written -- t(), tc(), tNode(), plural()
|
* 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
|
* 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
|
* 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.
|
* 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
|
* 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
|
||||||
* "[email protected]" would be a bug, not a feature -- so they would otherwise
|
* "[email protected]" would be a bug, not a feature -- so they would otherwise
|
||||||
* be reported for ever.
|
* 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/"))) {
|
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);
|
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
|
* 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
|
* 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
|
* 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}`}`.
|
* 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:
|
* 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.
|
* `${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
|
* 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)) {
|
&& n.expression.expression.getText(src) === "toast" && TOASTS.has(n.expression.name.text)) {
|
||||||
const a0 = n.arguments[0];
|
const a0 = n.arguments[0];
|
||||||
if (a0 && ts.isStringLiteral(a0) && !wrapped.has(a0)) report(a0, a0.text);
|
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 + "{}");
|
if (a0 && ts.isTemplateExpression(a0)) report(a0, a0.head.text + "{}");
|
||||||
}
|
}
|
||||||
ts.forEachChild(n, visit);
|
ts.forEachChild(n, visit);
|
||||||
@@ -160,11 +160,11 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!found.length) {
|
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);
|
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)}`);
|
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("\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);
|
process.exit(process.argv.includes("--check") ? 1 : 0);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
* 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
|
* 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
|
* exists is dead weight, but a call with no key is an untranslated string
|
||||||
* nobody noticed.
|
* nobody noticed.
|
||||||
|
|||||||
@@ -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 () => {
|
test("2FA is switched off with the password and a current code", async () => {
|
||||||
const state = await call("/api/account/security");
|
const state = await call("/api/account/security");
|
||||||
assert.equal(state.body.otpEnabled, true);
|
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.
|
// from the authenticator - here, the one the mock stored.
|
||||||
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
|
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
|
||||||
const params = parseOtpauthUrl(stored!);
|
const params = parseOtpauthUrl(stored!);
|
||||||
|
|||||||
@@ -169,10 +169,10 @@ export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start enrolment: mint a secret and hand back the URL to show as a QR code.
|
* Start enrollment: mint a secret and hand back the URL to show as a QR code.
|
||||||
* Nothing is stored until the user proves they can produce a code from it.
|
* Nothing is stored until the user proves they can produce a code from it.
|
||||||
*/
|
*/
|
||||||
export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
|
export function beginOtpEnrollment(ctx: Ctx): { secret: string; url: string } {
|
||||||
const secret = generateSecret();
|
const secret = generateSecret();
|
||||||
return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) };
|
return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) };
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,7 @@ export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
|
|||||||
* the new secret, so without this an authenticator that was mistyped or out of
|
* the new secret, so without this an authenticator that was mistyped or out of
|
||||||
* step would lock the user out of their mailbox at the next sign-in.
|
* step would lock the user out of their mailbox at the next sign-in.
|
||||||
*/
|
*/
|
||||||
export function assertEnrolmentCode(url: string, code: string): void {
|
export function assertEnrollmentCode(url: string, code: string): void {
|
||||||
const params = parseOtpauthUrl(url);
|
const params = parseOtpauthUrl(url);
|
||||||
if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url");
|
if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url");
|
||||||
if (!verifyTotp(params, code)) {
|
if (!verifyTotp(params, code)) {
|
||||||
@@ -193,7 +193,7 @@ export function assertEnrolmentCode(url: string, code: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
|
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
|
||||||
assertEnrolmentCode(opts.url, opts.code);
|
assertEnrollmentCode(opts.url, opts.code);
|
||||||
const res = await jmap(ctx, [
|
const res = await jmap(ctx, [
|
||||||
[
|
[
|
||||||
"x:AccountPassword/set",
|
"x:AccountPassword/set",
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ test("no capabilities at all is treated the same way", async () => {
|
|||||||
const STALWART = "urn:stalwart:jmap";
|
const STALWART = "urn:stalwart:jmap";
|
||||||
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
||||||
|
|
||||||
test("a 0.16 server is recognised from primaryAccounts, where it advertises itself", () => {
|
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
|
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a 0.16 server is recognised from an account's capabilities", () => {
|
test("a 0.16 server is recognized from an account's capabilities", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasStalwartRegistry({
|
hasStalwartRegistry({
|
||||||
capabilities: baseCaps,
|
capabilities: baseCaps,
|
||||||
@@ -100,7 +100,7 @@ test("a server that advertises it nowhere is one we do not support", () => {
|
|||||||
assert.equal(hasStalwartRegistry(undefined), false);
|
assert.equal(hasStalwartRegistry(undefined), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a shared account carrying the capability is enough to recognise the server", () => {
|
test("a shared account carrying the capability is enough to recognize the server", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasStalwartRegistry({
|
hasStalwartRegistry({
|
||||||
capabilities: baseCaps,
|
capabilities: baseCaps,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* An allowlist rather than a list of administrative objects, because the
|
* An allowlist rather than a list of administrative objects, because the
|
||||||
* registry has dozens of them -- listeners, stores, tracers, system settings --
|
* registry has dozens of them -- listeners, stores, tracers, system settings --
|
||||||
* and a new release adds more. An object not named here is refused, which errs
|
* and a new release adds more. An object not named here is refused, which errs
|
||||||
* towards the operator's decision.
|
* toward the operator's decision.
|
||||||
*
|
*
|
||||||
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||||
* touched: they act on what the account can already reach.
|
* touched: they act on what the account can already reach.
|
||||||
@@ -65,7 +65,7 @@ export function mayNameRegistryMethod(raw: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check a JMAP request body. On success, hands back the body to forward --
|
* Check a JMAP request body. On success, hands back the body to forward --
|
||||||
* serialised from what was inspected, so the server can never be sent
|
* serialized from what was inspected, so the server can never be sent
|
||||||
* something different from what was checked (a duplicate key, say, read one
|
* something different from what was checked (a duplicate key, say, read one
|
||||||
* way here and another way there).
|
* way here and another way there).
|
||||||
*/
|
*/
|
||||||
|
|||||||
+7
-7
@@ -29,8 +29,8 @@ import {
|
|||||||
} from "./upstream.js";
|
} from "./upstream.js";
|
||||||
import {
|
import {
|
||||||
AccountError,
|
AccountError,
|
||||||
assertEnrolmentCode,
|
assertEnrollmentCode,
|
||||||
beginOtpEnrolment,
|
beginOtpEnrollment,
|
||||||
changePassword,
|
changePassword,
|
||||||
createAppPassword,
|
createAppPassword,
|
||||||
disableOtp,
|
disableOtp,
|
||||||
@@ -404,7 +404,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* A 401 is a judgement about the password and stays counted. Anything
|
* A 401 is a judgment about the password and stays counted. Anything
|
||||||
* else -- refused, timed out, DNS, TLS -- is the upstream failing to
|
* else -- refused, timed out, DNS, TLS -- is the upstream failing to
|
||||||
* answer, which says nothing about the credentials and must not spend
|
* answer, which says nothing about the credentials and must not spend
|
||||||
* somebody's attempts while they wait for it to come back (#239).
|
* somebody's attempts while they wait for it to come back (#239).
|
||||||
@@ -559,7 +559,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
api.post("/account/2fa/begin", requireSession, async (c) => {
|
api.post("/account/2fa/begin", requireSession, async (c) => {
|
||||||
try {
|
try {
|
||||||
// Nothing is stored yet; the client hands the URL back to confirm.
|
// Nothing is stored yet; the client hands the URL back to confirm.
|
||||||
return c.json(beginOtpEnrolment(await accountCtx(c)));
|
return c.json(beginOtpEnrollment(await accountCtx(c)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -584,7 +584,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
* the moment 2FA is enabled this session can no longer authenticate at all.
|
* the moment 2FA is enabled this session can no longer authenticate at all.
|
||||||
*/
|
*/
|
||||||
try {
|
try {
|
||||||
assertEnrolmentCode(body.url, code);
|
assertEnrollmentCode(body.url, code);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -691,7 +691,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
|
|
||||||
// ---------- Administration: Stalwart's permission list ----------
|
// ---------- Administration: Stalwart's permission list ----------
|
||||||
/*
|
/*
|
||||||
* The one administration read that is not a JMAP call: the labelled list of
|
* The one administration read that is not a JMAP call: the labeled list of
|
||||||
* permissions from Stalwart's schema, for the Roles picker. Behind the same
|
* permissions from Stalwart's schema, for the Roles picker. Behind the same
|
||||||
* two gates as the registry methods, so a session that may not administer
|
* two gates as the registry methods, so a session that may not administer
|
||||||
* learns nothing from it.
|
* learns nothing from it.
|
||||||
@@ -950,7 +950,7 @@ const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "con
|
|||||||
* small IncomingMessage/ServerResponse pair.
|
* small IncomingMessage/ServerResponse pair.
|
||||||
*
|
*
|
||||||
* Returns a Response Hono treats as already sent: the raw bindings are
|
* Returns a Response Hono treats as already sent: the raw bindings are
|
||||||
* written to directly, and the returned value is never serialised.
|
* written to directly, and the returned value is never serialized.
|
||||||
*/
|
*/
|
||||||
const SSE_HEADERS = {
|
const SSE_HEADERS = {
|
||||||
"content-type": "text/event-stream",
|
"content-type": "text/event-stream",
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ test("junk in the chain is discarded rather than used as a key", () => {
|
|||||||
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "" }, cfg), "127.0.0.1");
|
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "" }, cfg), "127.0.0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bracketed and IPv4-mapped forms are normalised", () => {
|
test("bracketed and IPv4-mapped forms are normalized", () => {
|
||||||
assert.equal(resolveClientIp("::1", { forwardedFor: "[2001:db8::5]" }, cfg), "2001:db8::5");
|
assert.equal(resolveClientIp("::1", { forwardedFor: "[2001:db8::5]" }, cfg), "2001:db8::5");
|
||||||
assert.equal(resolveClientIp("::1", { forwardedFor: "::ffff:198.51.100.7" }, cfg), "198.51.100.7");
|
assert.equal(resolveClientIp("::1", { forwardedFor: "::ffff:198.51.100.7" }, cfg), "198.51.100.7");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export function parseStalwartServers(raw: unknown, file: string): { urls: Record
|
|||||||
a mapping that silently never matches. */
|
a mapping that silently never matches. */
|
||||||
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
|
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
|
||||||
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
|
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
|
||||||
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalised`);
|
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalized`);
|
||||||
/* A domain's value is its server's URL, or an object that also names where
|
/* A domain's value is its server's URL, or an object that also names where
|
||||||
that server's own administration is: `{"url": …, "adminUrl": …}`. */
|
that server's own administration is: `{"url": …, "adminUrl": …}`. */
|
||||||
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
|
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
|
||||||
@@ -356,7 +356,7 @@ export const config = {
|
|||||||
compressJmap: process.env.COMPRESS_JMAP !== "0",
|
compressJmap: process.env.COMPRESS_JMAP !== "0",
|
||||||
/*
|
/*
|
||||||
* How push reaches the browser. "relay" holds one upstream stream per tab
|
* How push reaches the browser. "relay" holds one upstream stream per tab
|
||||||
* (today's behaviour). "subscribe" registers one JMAP PushSubscription per
|
* (today's behavior). "subscribe" registers one JMAP PushSubscription per
|
||||||
* account and fans Stalwart's POSTs out to that account's tabs, holding no
|
* account and fans Stalwart's POSTs out to that account's tabs, holding no
|
||||||
* upstream connection at all -- see push.ts. It needs PUSH_URL: the https
|
* upstream connection at all -- see push.ts. It needs PUSH_URL: the https
|
||||||
* origin Stalwart can reach ihasmail at, with a certificate it trusts.
|
* origin Stalwart can reach ihasmail at, with a certificate it trusts.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ test("an unmapped domain still goes to the default while others are mapped", ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("the domain is matched however it was typed", () => {
|
test("the domain is matched however it was typed", () => {
|
||||||
// Keys are normalised on load; the username has to be normalised the same
|
// Keys are normalized on load; the username has to be normalized the same
|
||||||
// way or a mapping silently never matches.
|
// way or a mapping silently never matches.
|
||||||
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const { createApp } = await import("./app.js");
|
|||||||
* it is pointed at.
|
* it is pointed at.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
test("addresses we must never reach are recognised", () => {
|
test("addresses we must never reach are recognized", () => {
|
||||||
for (const a of [
|
for (const a of [
|
||||||
"127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1",
|
"127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1",
|
||||||
"169.254.169.254", // cloud metadata, the classic SSRF target
|
"169.254.169.254", // cloud metadata, the classic SSRF target
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function createDirectory(opts: Options) {
|
|||||||
{ id: "k2", "@type": "Dkim1RsaSha256", domainId: "d1", selector: "v1-rsa-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
{ id: "k2", "@type": "Dkim1RsaSha256", domainId: "d1", selector: "v1-rsa-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
||||||
{ id: "k3", "@type": "Dkim1Ed25519Sha256", domainId: "d2", selector: "v1-ed25519-20260710", stage: "active", createdAt: "2026-07-10T09:00:00Z", nextTransitionAt: null, memberTenantId: null },
|
{ id: "k3", "@type": "Dkim1Ed25519Sha256", domainId: "d2", selector: "v1-ed25519-20260710", stage: "active", createdAt: "2026-07-10T09:00:00Z", nextTransitionAt: null, memberTenantId: null },
|
||||||
];
|
];
|
||||||
/** What Stalwart's BIND serialiser writes, including a TXT long enough to be split. */
|
/** What Stalwart's BIND serializer writes, including a TXT long enough to be split. */
|
||||||
const zoneFile = (d: Obj): string => {
|
const zoneFile = (d: Obj): string => {
|
||||||
const n = String(d.name);
|
const n = String(d.name);
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
|||||||
*/
|
*/
|
||||||
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
||||||
/**
|
/**
|
||||||
* Stalwart advertises FUTURERELEASE in the session but only honours it when
|
* Stalwart advertises FUTURERELEASE in the session but only honors it when
|
||||||
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
||||||
* off, in which case the hold is dropped without a word and the message goes
|
* off, in which case the hold is dropped without a word and the message goes
|
||||||
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
|
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
|
||||||
@@ -200,7 +200,7 @@ function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string,
|
|||||||
* A marketing template of the shape #290 was reported against.
|
* A marketing template of the shape #290 was reported against.
|
||||||
*
|
*
|
||||||
* Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a
|
* Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a
|
||||||
* `<style>` block, a coloured call to action, a grey footer — and that is the
|
* `<style>` block, a colored call to action, a gray footer — and that is the
|
||||||
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
|
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
|
||||||
* mock without one could not show what "apply the theme to messages too" does
|
* mock without one could not show what "apply the theme to messages too" does
|
||||||
* to the mail people actually receive: nothing at all.
|
* to the mail people actually receive: nothing at all.
|
||||||
@@ -706,7 +706,7 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
|||||||
* answer. One event still comes back as a bare object, the shape this returned
|
* answer. One event still comes back as a bare object, the shape this returned
|
||||||
* when an invitation was all it had to handle.
|
* when an invitation was all it had to handle.
|
||||||
*
|
*
|
||||||
* The synthetic organiser and attendee only go on events that arrived with a
|
* The synthetic organizer and attendee only go on events that arrived with a
|
||||||
* METHOD. Those are scheduling messages, which is what the invitation fixtures
|
* METHOD. Those are scheduling messages, which is what the invitation fixtures
|
||||||
* are; a plain export is not addressed to anyone, and inventing participants
|
* are; a plain export is not addressed to anyone, and inventing participants
|
||||||
* for it would make imported events look like invitations nobody sent.
|
* for it would make imported events look like invitations nobody sent.
|
||||||
@@ -920,7 +920,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
"Email/query": (a) => {
|
"Email/query": (a) => {
|
||||||
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
|
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
|
||||||
/*
|
/*
|
||||||
* Honour the sort rather than always answering newest-first. This used to
|
* Honor the sort rather than always answering newest-first. This used to
|
||||||
* ignore it entirely, which reproduced a server that silently returns a
|
* ignore it entirely, which reproduced a server that silently returns a
|
||||||
* different order from the one asked for -- the one shape of wrongness a
|
* different order from the one asked for -- the one shape of wrongness a
|
||||||
* client cannot detect.
|
* client cannot detect.
|
||||||
@@ -1040,7 +1040,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
return setResp({ updated: { singleton: null } });
|
return setResp({ updated: { singleton: null } });
|
||||||
},
|
},
|
||||||
/*
|
/*
|
||||||
* Push subscriptions. The JMAP half can be modelled; delivery cannot -- that
|
* Push subscriptions. The JMAP half can be modeled; delivery cannot -- that
|
||||||
* runs through the browser vendor's real push service, so nothing local will
|
* runs through the browser vendor's real push service, so nothing local will
|
||||||
* ever make a notification appear.
|
* ever make a notification appear.
|
||||||
*
|
*
|
||||||
@@ -1225,7 +1225,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
}
|
}
|
||||||
const status = undoStatusOf(sub, Date.now());
|
const status = undoStatusOf(sub, Date.now());
|
||||||
if (status !== "pending") {
|
if (status !== "pending") {
|
||||||
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already cancelled." : "The message has already been sent." };
|
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already canceled." : "The message has already been sent." };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sub.undoStatus = "canceled";
|
sub.undoStatus = "canceled";
|
||||||
@@ -1370,7 +1370,7 @@ function checkAuth(req: IncomingMessage): boolean {
|
|||||||
const u = raw.slice(0, sep);
|
const u = raw.slice(0, sep);
|
||||||
const p = raw.slice(sep + 1);
|
const p = raw.slice(sep + 1);
|
||||||
if (u !== USER) return false;
|
if (u !== USER) return false;
|
||||||
// App passwords are recognised by shape and skip the second factor, which is
|
// App passwords are recognized by shape and skip the second factor, which is
|
||||||
// exactly what lets a webmail session survive 2FA being switched on.
|
// exactly what lets a webmail session survive 2FA being switched on.
|
||||||
if (account.appPasswords.some((a) => a.secret === p)) return true;
|
if (account.appPasswords.some((a) => a.secret === p)) return true;
|
||||||
if (!account.otpUrl) return p === account.password;
|
if (!account.otpUrl) return p === account.password;
|
||||||
@@ -1501,7 +1501,7 @@ export const server = createServer(async (req, res) => {
|
|||||||
* **Confirmed live on 0.16.21 (2026-09-06):** the interval is in **seconds**
|
* **Confirmed live on 0.16.21 (2026-09-06):** the interval is in **seconds**
|
||||||
* — `data: {"interval": 30}` — where up to 0.16.20 the same field carried
|
* — `data: {"interval": 30}` — where up to 0.16.20 the same field carried
|
||||||
* milliseconds. The server floors it at 30 s (asking for 1, 2 or 5 all
|
* milliseconds. The server floors it at 30 s (asking for 1, 2 or 5 all
|
||||||
* answered 30 and pinged every 30 s) and honours anything above (45 pinged
|
* answered 30 and pinged every 30 s) and honors anything above (45 pinged
|
||||||
* at 45 s and said 45, 60 at 60 and said 60). `ping=0` disables pings
|
* at 45 s and said 45, 60 at 60 and said 60). `ping=0` disables pings
|
||||||
* altogether; a value that is not a number at all — `abc`, or empty — is a
|
* altogether; a value that is not a number at all — `abc`, or empty — is a
|
||||||
* 400 before the stream opens.
|
* 400 before the stream opens.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe("expandOccurrences", () => {
|
|||||||
assert.equal(out[0]!.index, 0);
|
assert.equal(out[0]!.index, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honours count", () => {
|
it("honors count", () => {
|
||||||
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
|
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
|
||||||
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
|
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
|
||||||
assert.equal(expandOccurrences(ev, a, b).length, 3);
|
assert.equal(expandOccurrences(ev, a, b).length, 3);
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRul
|
|||||||
* The object a `CalendarEvent/get` returns for one occurrence.
|
* The object a `CalendarEvent/get` returns for one occurrence.
|
||||||
*
|
*
|
||||||
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
|
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
|
||||||
* master — so an occurrence is recognisable by its `recurrenceId` and by
|
* master — so an occurrence is recognizable by its `recurrenceId` and by
|
||||||
* nothing else, which is the shape `isRecurring` was written against.
|
* nothing else, which is the shape `isRecurring` was written against.
|
||||||
*/
|
*/
|
||||||
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* These are not hand-written. Each was produced by `openssl smime -sign` with a
|
* These are not hand-written. Each was produced by `openssl smime -sign` with a
|
||||||
* generated certificate and is stored base64 so no editor, formatter or
|
* generated certificate and is stored base64 so no editor, formatter or
|
||||||
* checkout setting can touch a byte of it -- a signature is over exact octets,
|
* checkout setting can touch a byte of it -- a signature is over exact octets,
|
||||||
* and a stray line-ending normalisation would turn a working fixture into a
|
* and a stray line-ending normalization would turn a working fixture into a
|
||||||
* broken one for reasons invisible in a diff.
|
* broken one for reasons invisible in a diff.
|
||||||
*
|
*
|
||||||
* The same files back the unit tests, in web/src/lib/smime/__tests__/fixtures.
|
* The same files back the unit tests, in web/src/lib/smime/__tests__/fixtures.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test("the account's permissions are kept alongside the edition", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("permission names read the same whichever case the server uses", () => {
|
test("permission names read the same whichever case the server uses", () => {
|
||||||
// The source serialises camelCase; the documentation shows kebab-case.
|
// The source serializes camelCase; the documentation shows kebab-case.
|
||||||
assert.equal(normalizePermission("sys-account-get"), "sysAccountGet");
|
assert.equal(normalizePermission("sys-account-get"), "sysAccountGet");
|
||||||
assert.equal(normalizePermission("sysAccountGet"), "sysAccountGet");
|
assert.equal(normalizePermission("sysAccountGet"), "sysAccountGet");
|
||||||
assert.equal(normalizePermission("sys-dkim-signature-create"), "sysDkimSignatureCreate");
|
assert.equal(normalizePermission("sys-dkim-signature-create"), "sysDkimSignatureCreate");
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export interface CreateSessionParams {
|
|||||||
* other sessions" button: `app.ts` also calls it when the password or the app
|
* other sessions" button: `app.ts` also calls it when the password or the app
|
||||||
* password changes, so it carries the guarantee that changing a credential
|
* password changes, so it carries the guarantee that changing a credential
|
||||||
* invalidates the sessions still holding the old one. A stateless backend
|
* invalidates the sessions still holding the old one. A stateless backend
|
||||||
* cannot honour that alone; the plan is for OAuth to hand the job to
|
* cannot honor that alone; the plan is for OAuth to hand the job to
|
||||||
* Stalwart's own token registry, which can already answer both questions.
|
* Stalwart's own token registry, which can already answer both questions.
|
||||||
*/
|
*/
|
||||||
export interface SessionBackend {
|
export interface SessionBackend {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ test("every entry in the example mapping is a domain and an http(s) URL", () =>
|
|||||||
if (key.startsWith("_")) continue;
|
if (key.startsWith("_")) continue;
|
||||||
const domain = key.trim().toLowerCase().replace(/\.$/, "");
|
const domain = key.trim().toLowerCase().replace(/\.$/, "");
|
||||||
assert.ok(domain, "a domain key is empty");
|
assert.ok(domain, "a domain key is empty");
|
||||||
assert.ok(!seen.has(domain), `${domain} appears twice once normalised`);
|
assert.ok(!seen.has(domain), `${domain} appears twice once normalized`);
|
||||||
seen.add(domain);
|
seen.add(domain);
|
||||||
// A URL, or an object naming the server's URL and its administration's.
|
// A URL, or an object naming the server's URL and its administration's.
|
||||||
const entry = value && typeof value === "object" ? (value as Record<string, unknown>) : { url: value };
|
const entry = value && typeof value === "object" ? (value as Record<string, unknown>) : { url: value };
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
* already being read here, so checking what it asks for costs one substring
|
* already being read here, so checking what it asks for costs one substring
|
||||||
* search per rebuild and turns a mystery into a line in the log.
|
* search per rebuild and turns a mystery into a line in the log.
|
||||||
*
|
*
|
||||||
* A warning rather than a refusal: this reads a built artefact to guess at a
|
* A warning rather than a refusal: this reads a built artifact to guess at a
|
||||||
* misconfiguration, and a wrong guess that stops the server from starting is
|
* misconfiguration, and a wrong guess that stops the server from starting is
|
||||||
* worse than the problem it is describing.
|
* worse than the problem it is describing.
|
||||||
*/
|
*/
|
||||||
@@ -128,7 +128,7 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
* comes off once, here. Anything outside it is a 404 and not the app
|
* comes off once, here. Anything outside it is a 404 and not the app
|
||||||
* shell: under `/mail` this process shares a hostname with whatever else
|
* shell: under `/mail` this process shares a hostname with whatever else
|
||||||
* the proxy serves, and answering `/` or `/other-app/thing` with our
|
* the proxy serves, and answering `/` or `/other-app/thing` with our
|
||||||
* index would shadow a neighbour rather than let it 404 honestly.
|
* index would shadow a neighbor rather than let it 404 honestly.
|
||||||
*/
|
*/
|
||||||
const fullPath = decodeURIComponent(new URL(c.req.url).pathname);
|
const fullPath = decodeURIComponent(new URL(c.req.url).pathname);
|
||||||
const urlPath = stripBasePath(basePath, fullPath);
|
const urlPath = stripBasePath(basePath, fullPath);
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TOTP (RFC 6238) — just enough to enrol a second factor safely.
|
* TOTP (RFC 6238) — just enough to enroll a second factor safely.
|
||||||
*
|
*
|
||||||
* Stalwart stores the otpauth:// URL and checks codes at login, but it does
|
* Stalwart stores the otpauth:// URL and checks codes at login, but it does
|
||||||
* *not* check the new secret when 2FA is switched on: it verifies the
|
* *not* check the new secret when 2FA is switched on: it verifies the
|
||||||
* credentials that are already on the account. A user whose authenticator was
|
* credentials that are already on the account. A user whose authenticator was
|
||||||
* mistyped or whose clock has drifted would be locked out of their mailbox at
|
* mistyped or whose clock has drifted would be locked out of their mailbox at
|
||||||
* the next sign-in. So ihasmail proves the enrolment itself, before asking the
|
* the next sign-in. So ihasmail proves the enrollment itself, before asking the
|
||||||
* server to store anything.
|
* server to store anything.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ const SCRIPT_MODIFIERS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalise a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
|
* Normalize a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
|
||||||
* ("de-DE"). Returns null for the locale-less values ("C", "POSIX") and for
|
* ("de-DE"). Returns null for the locale-less values ("C", "POSIX") and for
|
||||||
* anything that does not look like a language tag.
|
* anything that does not look like a language tag.
|
||||||
*/
|
*/
|
||||||
@@ -336,10 +336,10 @@ function localeOf(call: [string, Record<string, unknown>, string] | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission names in the form the source serialises them.
|
* Permission names in the form the source serializes them.
|
||||||
*
|
*
|
||||||
* Stalwart 0.16 builds `/api/account`'s list from the same enum as everything
|
* Stalwart 0.16 builds `/api/account`'s list from the same enum as everything
|
||||||
* else, which serialises as camelCase (`sysAccountGet`). Its documentation and
|
* else, which serializes as camelCase (`sysAccountGet`). Its documentation and
|
||||||
* OpenAPI example show kebab-case (`sys-account-get`) instead. Until a live
|
* OpenAPI example show kebab-case (`sys-account-get`) instead. Until a live
|
||||||
* server settles which is true, both are read as the one form, so a check
|
* server settles which is true, both are read as the one form, so a check
|
||||||
* written against `sysAccountGet` holds either way.
|
* written against `sysAccountGet` holds either way.
|
||||||
@@ -426,7 +426,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
|
|||||||
* So by default only the path and query are taken from the advertised URL;
|
* So by default only the path and query are taken from the advertised URL;
|
||||||
* scheme, host and port come from the configured base. That is what a proxy
|
* scheme, host and port come from the configured base. That is what a proxy
|
||||||
* should have done all along -- the operator named the route on purpose.
|
* should have done all along -- the operator named the route on purpose.
|
||||||
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behaviour for a setup
|
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behavior for a setup
|
||||||
* that genuinely needs to reach Stalwart at a different origin than the one
|
* that genuinely needs to reach Stalwart at a different origin than the one
|
||||||
* it was given.
|
* it was given.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
theme, which a media query cannot do — it only knows what the OS prefers,
|
theme, which a media query cannot do — it only knows what the OS prefers,
|
||||||
not what the user picked here. There used to be two, both with media
|
not what the user picked here. There used to be two, both with media
|
||||||
attributes, which meant the selector in applyTheme (:not([media])) matched
|
attributes, which meant the selector in applyTheme (:not([media])) matched
|
||||||
neither and the colour never moved off whatever the OS implied.
|
neither and the color never moved off whatever the OS implied.
|
||||||
|
|
||||||
The initial value is the default theme's background, so the browser chrome
|
The initial value is the default theme's background, so the browser chrome
|
||||||
is right from the first paint rather than only once JS has run.
|
is right from the first paint rather than only once JS has run.
|
||||||
|
|||||||
+10
-10
@@ -44,7 +44,7 @@ export function App() {
|
|||||||
* knowing its strings just changed. Rather than make every one of the
|
* knowing its strings just changed. Rather than make every one of the
|
||||||
* thousand call sites a subscriber -- which would turn extracting a string
|
* thousand call sites a subscriber -- which would turn extracting a string
|
||||||
* from "wrap it" into "wrap it and add a hook" -- the whole tree is thrown
|
* from "wrap it" into "wrap it and add a hook" -- the whole tree is thrown
|
||||||
* away and rebuilt when the catalogue changes. Picking a language is a
|
* away and rebuilt when the catalog changes. Picking a language is a
|
||||||
* once-in-an-account event; paying for it there is far cheaper than paying
|
* once-in-an-account event; paying for it there is far cheaper than paying
|
||||||
* for it on every render everywhere.
|
* for it on every render everywhere.
|
||||||
*/
|
*/
|
||||||
@@ -54,9 +54,9 @@ export function App() {
|
|||||||
}, [bootstrap]);
|
}, [bootstrap]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Wait for the catalogue before the first paint.
|
* Wait for the catalog before the first paint.
|
||||||
*
|
*
|
||||||
* The tree is rebuilt when a catalogue lands, so components recover on
|
* The tree is rebuilt when a catalog lands, so components recover on
|
||||||
* their own -- but a string computed in an effect does not. A toast fired
|
* their own -- but a string computed in an effect does not. A toast fired
|
||||||
* in the gap is emitted in English and stays English, in an interface that
|
* in the gap is emitted in English and stays English, in an interface that
|
||||||
* is otherwise not. The wait costs nothing visible: the session bootstrap
|
* is otherwise not. The wait costs nothing visible: the session bootstrap
|
||||||
@@ -129,7 +129,7 @@ function AuthedApp() {
|
|||||||
* or the sign-out that every deploy causes -- the first frame is the
|
* or the sign-out that every deploy causes -- the first frame is the
|
||||||
* defaults, and the defaults are English. Rendering then means anything
|
* defaults, and the defaults are English. Rendering then means anything
|
||||||
* computed before the settings land is computed in the wrong language: not
|
* computed before the settings land is computed in the wrong language: not
|
||||||
* the interface, which is rebuilt when the catalogue arrives, but a string
|
* the interface, which is rebuilt when the catalog arrives, but a string
|
||||||
* emitted once, like a toast. That is why the stale-folder toast came out
|
* emitted once, like a toast. That is why the stale-folder toast came out
|
||||||
* in English on an otherwise German screen.
|
* in English on an otherwise German screen.
|
||||||
*
|
*
|
||||||
@@ -148,14 +148,14 @@ function AuthedApp() {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let canceled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
/* Before the account's own settings, so both the seeding below and the
|
/* Before the account's own settings, so both the seeding below and the
|
||||||
enforcement inside `hydrate` have something to apply. */
|
enforcement inside `hydrate` have something to apply. */
|
||||||
await loadSettingsPolicy();
|
await loadSettingsPolicy();
|
||||||
if (cancelled) return;
|
if (canceled) return;
|
||||||
const remote = await loadRemoteSettings();
|
const remote = await loadRemoteSettings();
|
||||||
if (cancelled) return;
|
if (canceled) return;
|
||||||
if (remote) useSettings.getState().hydrate(remote);
|
if (remote) useSettings.getState().hydrate(remote);
|
||||||
// No settings file: this account has never had settings of its own, so
|
// No settings file: this account has never had settings of its own, so
|
||||||
// the installation's defaults are what it starts on rather than
|
// the installation's defaults are what it starts on rather than
|
||||||
@@ -174,10 +174,10 @@ function AuthedApp() {
|
|||||||
other: "Your administrator changed {n} settings",
|
other: "Your administrator changed {n} settings",
|
||||||
}), { action: { label: t("Settings"), onClick: () => { window.location.href = withBase("/settings/general"); } } });
|
}), { action: { label: t("Settings"), onClick: () => { window.location.href = withBase("/settings/general"); } } });
|
||||||
}
|
}
|
||||||
// The catalogue for whatever language that turned out to be. Hydrating
|
// The catalog for whatever language that turned out to be. Hydrating
|
||||||
// asks for it; this is waiting for the answer.
|
// asks for it; this is waiting for the answer.
|
||||||
await whenLanguageReady();
|
await whenLanguageReady();
|
||||||
if (cancelled) return;
|
if (canceled) return;
|
||||||
setReady(true);
|
setReady(true);
|
||||||
// Pushes were held back until now so they could not race the load. A
|
// Pushes were held back until now so they could not race the load. A
|
||||||
// change made while it was in flight was kept, and goes out here.
|
// change made while it was in flight was kept, and goes out here.
|
||||||
@@ -187,7 +187,7 @@ function AuthedApp() {
|
|||||||
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
}, [accountId]);
|
}, [accountId]);
|
||||||
|
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export class JmapClient {
|
|||||||
else reject(new ApiError(xhr.status, (xhr.response as ApiErrorBody)?.error ?? "upload_failed", (xhr.response as ApiErrorBody)?.message ?? "Upload failed"));
|
else reject(new ApiError(xhr.status, (xhr.response as ApiErrorBody)?.error ?? "upload_failed", (xhr.response as ApiErrorBody)?.message ?? "Upload failed"));
|
||||||
};
|
};
|
||||||
xhr.onerror = () => reject(new ApiError(0, "network_error", "Network error during upload"));
|
xhr.onerror = () => reject(new ApiError(0, "network_error", "Network error during upload"));
|
||||||
xhr.onabort = () => reject(new ApiError(0, "aborted", "Upload cancelled"));
|
xhr.onabort = () => reject(new ApiError(0, "aborted", "Upload canceled"));
|
||||||
opts.signal?.addEventListener("abort", () => xhr.abort());
|
opts.signal?.addEventListener("abort", () => xhr.abort());
|
||||||
xhr.send(data);
|
xhr.send(data);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe("generated passwords", () => {
|
|||||||
expect(p).not.toMatch(/[01lIO]/);
|
expect(p).not.toMatch(/[01lIO]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skip bytes that would favour the start of the alphabet", () => {
|
it("skip bytes that would favor the start of the alphabet", () => {
|
||||||
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
|
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
|
||||||
// the first 36 characters twice as often. Bytes of 220 and up are dropped
|
// the first 36 characters twice as often. Bytes of 220 and up are dropped
|
||||||
// and more are drawn, so a batch of nothing but those costs a draw.
|
// and more are drawn, so a batch of nothing but those costs a draw.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { client, JmapMethodError } from "@/jmap/client";
|
import { client, JmapMethodError } from "@/jmap/client";
|
||||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summariseMetrics, type MetricRecord } from "@/lib/adminDashboard";
|
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/adminDashboard";
|
||||||
|
|
||||||
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
|
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
|
||||||
|
|
||||||
describe("the dashboard's message numbers", () => {
|
describe("the dashboard's message numbers", () => {
|
||||||
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
|
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
|
||||||
const stats = summariseMetrics([
|
const stats = summarizeMetrics([
|
||||||
counter("queue.message-queued", 6),
|
counter("queue.message-queued", 6),
|
||||||
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
|
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
|
||||||
counter("queue.authenticated-message-queued", 2),
|
counter("queue.authenticated-message-queued", 2),
|
||||||
@@ -20,7 +20,7 @@ describe("the dashboard's message numbers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reads memory from the newest gauge, not the first one listed", () => {
|
it("reads memory from the newest gauge, not the first one listed", () => {
|
||||||
const stats = summariseMetrics([
|
const stats = summarizeMetrics([
|
||||||
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
|
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
|
||||||
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
|
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
|
||||||
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
|
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
|
||||||
@@ -29,8 +29,8 @@ describe("the dashboard's message numbers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("tells a history that records nothing from a quiet day", () => {
|
it("tells a history that records nothing from a quiet day", () => {
|
||||||
expect(summariseMetrics([]).recorded).toBe(false);
|
expect(summarizeMetrics([]).recorded).toBe(false);
|
||||||
const quiet = summariseMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
|
const quiet = summarizeMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
|
||||||
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
|
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe("the account query", () => {
|
|||||||
* live server gave, or one its source says it gives.
|
* live server gave, or one its source says it gives.
|
||||||
*/
|
*/
|
||||||
describe("refusals in the reader's language", () => {
|
describe("refusals in the reader's language", () => {
|
||||||
it("recognises the registry's validators and says it again, without the server's words", () => {
|
it("recognizes the registry's validators and says it again, without the server's words", () => {
|
||||||
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
|
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
|
||||||
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
|
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
|
||||||
expect(domain).toMatch(/isn't a valid domain name/);
|
expect(domain).toMatch(/isn't a valid domain name/);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { describeLinked, dkimAlgorithm, looksLikeDomain, normaliseDomain, parseZoneFile } from "@/lib/adminDomains";
|
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/adminDomains";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Written the way Stalwart's BIND serialiser writes it (dns-update's
|
* Written the way Stalwart's BIND serializer writes it (dns-update's
|
||||||
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
|
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
|
||||||
* parenthesised run of quoted chunks.
|
* parenthesized run of quoted chunks.
|
||||||
*/
|
*/
|
||||||
const long = "v=DKIM1; k=rsa; h=sha256; p=" + "A".repeat(400);
|
const long = "v=DKIM1; k=rsa; h=sha256; p=" + "A".repeat(400);
|
||||||
const zone = [
|
const zone = [
|
||||||
@@ -46,7 +46,7 @@ describe("reading the zone file", () => {
|
|||||||
|
|
||||||
describe("domain names", () => {
|
describe("domain names", () => {
|
||||||
it("are written back lower-case without the root dot", () => {
|
it("are written back lower-case without the root dot", () => {
|
||||||
expect(normaliseDomain(" Example.COM. ")).toBe("example.com");
|
expect(normalizeDomain(" Example.COM. ")).toBe("example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("are checked loosely before the server decides", () => {
|
it("are checked loosely before the server decides", () => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe("the span an availability bar covers", () => {
|
|||||||
expect(w.span).toBeGreaterThan(0);
|
expect(w.span).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a single day every three hours, labelling every six", () => {
|
it("marks a single day every three hours, labeling every six", () => {
|
||||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
|
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
|
||||||
expect(w.scale).toBe("hours");
|
expect(w.scale).toBe("hours");
|
||||||
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
|
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe("birthdaysInRange", () => {
|
|||||||
expect(birthdaysInRange([card("c2", "", { month: 6, day: 15 })], s, e)).toEqual([]);
|
expect(birthdaysInRange([card("c2", "", { month: 6, day: 15 })], s, e)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to a name built from components, then to the organisation", () => {
|
it("falls back to a name built from components, then to the organization", () => {
|
||||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||||
const parts = {
|
const parts = {
|
||||||
id: "c1",
|
id: "c1",
|
||||||
@@ -115,7 +115,7 @@ describe("birthdaysInRange", () => {
|
|||||||
expect(out.map((b) => b.name)).toEqual(["Amy", "Zoe"]);
|
expect(out.map((b) => b.name)).toEqual(["Amy", "Zoe"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives each occurrence a stable, unique id that marks it as synthesised", () => {
|
it("gives each occurrence a stable, unique id that marks it as synthesized", () => {
|
||||||
const [s, e] = range("2025-01-01", "2027-01-01");
|
const [s, e] = range("2025-01-01", "2027-01-01");
|
||||||
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
|
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
|
||||||
expect(new Set(out.map((b) => b.id)).size).toBe(out.length);
|
expect(new Set(out.map((b) => b.id)).size).toBe(out.length);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { DEFAULT_APP_NAME } from "@/lib/brand";
|
|||||||
*
|
*
|
||||||
* `APP_NAME` is a runtime variable, so every place showing the name has to ask
|
* `APP_NAME` is a runtime variable, so every place showing the name has to ask
|
||||||
* the server rather than have it written in. The sign-in page did not (#236's
|
* the server rather than have it written in. The sign-in page did not (#236's
|
||||||
* neighbour): it fetched `/api/config`, received the name and used only
|
* neighbor): it fetched `/api/config`, received the name and used only
|
||||||
* `sourceUrl`, so a rebranded instance still said "ihasmail" on the page a new
|
* `sourceUrl`, so a rebranded instance still said "ihasmail" on the page a new
|
||||||
* user meets first. These pin the shape of the answer rather than the name.
|
* user meets first. These pin the shape of the answer rather than the name.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -97,14 +97,14 @@ describe("explicit date formats", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("clock preference", () => {
|
describe("clock preference", () => {
|
||||||
it("honours 24-hour regardless of locale", () => {
|
it("honors 24-hour regardless of locale", () => {
|
||||||
setDateTimePrefs({ locale: "en-US", timeFormat: "24" });
|
setDateTimePrefs({ locale: "en-US", timeFormat: "24" });
|
||||||
expect(formatClock(SAMPLE)).toBe("18:23");
|
expect(formatClock(SAMPLE)).toBe("18:23");
|
||||||
expect(uses24Hour()).toBe(true);
|
expect(uses24Hour()).toBe(true);
|
||||||
expect(formatHourLabel(13)).toBe("13");
|
expect(formatHourLabel(13)).toBe("13");
|
||||||
expect(formatHourLabel(9)).toBe("09");
|
expect(formatHourLabel(9)).toBe("09");
|
||||||
});
|
});
|
||||||
it("honours 12-hour regardless of locale", () => {
|
it("honors 12-hour regardless of locale", () => {
|
||||||
setDateTimePrefs({ locale: "de-DE", timeFormat: "12" });
|
setDateTimePrefs({ locale: "de-DE", timeFormat: "12" });
|
||||||
expect(formatClock(SAMPLE)).toBe("6:23 PM");
|
expect(formatClock(SAMPLE)).toBe("6:23 PM");
|
||||||
expect(uses24Hour()).toBe(false);
|
expect(uses24Hour()).toBe(false);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* The two sentence builders, which had no tests while they were building
|
* The two sentence builders, which had no tests while they were building
|
||||||
* English by concatenation -- and no test would have caught the thing wrong
|
* English by concatenation -- and no test would have caught the thing wrong
|
||||||
* with them, since the English output was correct. These pin the two
|
* with them, since the English output was correct. These pin the two
|
||||||
* properties that matter now: every fragment goes through the catalogue, and
|
* properties that matter now: every fragment goes through the catalog, and
|
||||||
* the joining is Intl's rather than a hardcoded " and ".
|
* the joining is Intl's rather than a hardcoded " and ".
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
@@ -12,7 +12,7 @@ import { setUiLanguageForFormatting } from "../datetime";
|
|||||||
import { setCatalog } from "../i18n";
|
import { setCatalog } from "../i18n";
|
||||||
|
|
||||||
describe("sieve describeRule", () => {
|
describe("sieve describeRule", () => {
|
||||||
it("names the header and operator through the catalogue", () => {
|
it("names the header and operator through the catalog", () => {
|
||||||
const s = describeSieve({
|
const s = describeSieve({
|
||||||
id: "1", name: "r", join: "allof", enabled: true,
|
id: "1", name: "r", join: "allof", enabled: true,
|
||||||
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
|
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
|
||||||
@@ -50,7 +50,7 @@ describe("recurrence describeRule", () => {
|
|||||||
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
|
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recognises Monday to Friday as every weekday", () => {
|
it("recognizes Monday to Friday as every weekday", () => {
|
||||||
const rule = {
|
const rule = {
|
||||||
"@type": "RecurrenceRule", frequency: "weekly",
|
"@type": "RecurrenceRule", frequency: "weekly",
|
||||||
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
|
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
|
||||||
@@ -80,12 +80,12 @@ describe("recurrence describeRule", () => {
|
|||||||
expect(names[0]).toBe("Montag");
|
expect(names[0]).toBe("Montag");
|
||||||
expect(names).toHaveLength(7);
|
expect(names).toHaveLength(7);
|
||||||
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
|
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
|
||||||
// which is why they cannot be catalogue keys and come from Intl instead.
|
// which is why they cannot be catalog keys and come from Intl instead.
|
||||||
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
|
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
|
||||||
setUiLanguageForFormatting(null);
|
setUiLanguageForFormatting(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a translated rule through the catalogue", () => {
|
it("renders a translated rule through the catalog", () => {
|
||||||
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
|
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
|
||||||
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
|
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
|
||||||
setCatalog("en", { strings: {}, plurals: {} });
|
setCatalog("en", { strings: {}, plurals: {} });
|
||||||
|
|||||||
@@ -93,14 +93,14 @@ describe("canMoveFolderTo", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("folderColor", () => {
|
describe("folderColor", () => {
|
||||||
it("returns the colour chosen for that folder, and null for the rest", () => {
|
it("returns the color chosen for that folder, and null for the rest", () => {
|
||||||
const colors = { work: "#7c3aed" };
|
const colors = { work: "#7c3aed" };
|
||||||
expect(folderColor(colors, "work")).toBe("#7c3aed");
|
expect(folderColor(colors, "work")).toBe("#7c3aed");
|
||||||
expect(folderColor(colors, "news")).toBeNull();
|
expect(folderColor(colors, "news")).toBeNull();
|
||||||
expect(folderColor({}, "work")).toBeNull();
|
expect(folderColor({}, "work")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is keyed by id, so a renamed folder keeps its colour", () => {
|
it("is keyed by id, so a renamed folder keeps its color", () => {
|
||||||
// The id is stable across a rename; the name and path are not.
|
// The id is stable across a rename; the name and path are not.
|
||||||
expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e");
|
expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe("sanitizeEmailHtml", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("htmlDeclaresColors", () => {
|
describe("htmlDeclaresColors", () => {
|
||||||
it("is false for mail that brings no colours", () => {
|
it("is false for mail that brings no colors", () => {
|
||||||
expect(htmlDeclaresColors("<p>Hi there</p>")).toBe(false);
|
expect(htmlDeclaresColors("<p>Hi there</p>")).toBe(false);
|
||||||
expect(htmlDeclaresColors("<div><b>bold</b> and <i>italic</i></div>", "font-family:Arial")).toBe(false);
|
expect(htmlDeclaresColors("<div><b>bold</b> and <i>italic</i></div>", "font-family:Arial")).toBe(false);
|
||||||
expect(htmlDeclaresColors('<a href="https://x.io/?color=red">link</a>')).toBe(false);
|
expect(htmlDeclaresColors('<a href="https://x.io/?color=red">link</a>')).toBe(false);
|
||||||
@@ -54,9 +54,9 @@ describe("htmlDeclaresColors", () => {
|
|||||||
/**
|
/**
|
||||||
* Forcing the theme onto mail that styles itself — issue #290.
|
* Forcing the theme onto mail that styles itself — issue #290.
|
||||||
*
|
*
|
||||||
* The switch above it leaves nearly all HTML mail alone, because one colour
|
* The switch above it leaves nearly all HTML mail alone, because one color
|
||||||
* anywhere opts a message out. What this half has to get right is telling a
|
* anywhere opts a message out. What this half has to get right is telling a
|
||||||
* sheet the design sits on from a surface painted on top of it: neutralise the
|
* sheet the design sits on from a surface painted on top of it: neutralize the
|
||||||
* first and the white card goes away, keep the second and a button keeps a
|
* first and the white card goes away, keep the second and a button keeps a
|
||||||
* label you can still read.
|
* label you can still read.
|
||||||
*/
|
*/
|
||||||
@@ -70,15 +70,15 @@ describe("relativeLuminance", () => {
|
|||||||
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
|
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has nothing to say about a colour it cannot read", () => {
|
it("has nothing to say about a color it cannot read", () => {
|
||||||
// Not a failure: the caller treats null as "no deliberate surface", which
|
// Not a failure: the caller treats null as "no deliberate surface", which
|
||||||
// is the safe way round — an unreadable colour must not keep a white sheet.
|
// is the safe way round — an unreadable color must not keep a white sheet.
|
||||||
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
|
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
|
||||||
expect(relativeLuminance("var(--brand)")).toBeNull();
|
expect(relativeLuminance("var(--brand)")).toBeNull();
|
||||||
expect(relativeLuminance("")).toBeNull();
|
expect(relativeLuminance("")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a fully transparent colour as painting nothing", () => {
|
it("treats a fully transparent color as painting nothing", () => {
|
||||||
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
|
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
|
||||||
expect(relativeLuminance("transparent")).toBeNull();
|
expect(relativeLuminance("transparent")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -100,21 +100,21 @@ describe("markKeptSurfaces", () => {
|
|||||||
* Marking is only half of it — the other half is the rule in EMAIL_BASE_CSS
|
* Marking is only half of it — the other half is the rule in EMAIL_BASE_CSS
|
||||||
* that reads the marks, and #310 was a bug in that half rather than in the
|
* that reads the marks, and #310 was a bug in that half rather than in the
|
||||||
* marking. So these assert what the reader actually sees: does the
|
* marking. So these assert what the reader actually sees: does the
|
||||||
* neutraliser hit this element? The selector is lifted out of the stylesheet
|
* neutralizer hit this element? The selector is lifted out of the stylesheet
|
||||||
* rather than copied, so a test cannot quietly drift from the rule it checks.
|
* rather than copied, so a test cannot quietly drift from the rule it checks.
|
||||||
*/
|
*/
|
||||||
const NEUTRALISER = (() => {
|
const NEUTRALIZER = (() => {
|
||||||
const m = EMAIL_BASE_CSS.match(
|
const m = EMAIL_BASE_CSS.match(
|
||||||
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
|
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
|
||||||
);
|
);
|
||||||
if (!m) throw new Error("could not find the neutraliser rule in EMAIL_BASE_CSS");
|
if (!m) throw new Error("could not find the neutralizer rule in EMAIL_BASE_CSS");
|
||||||
return m[1]!.trim();
|
return m[1]!.trim();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** True when the theme is forced onto this element rather than leaving it alone. */
|
/** True when the theme is forced onto this element rather than leaving it alone. */
|
||||||
const neutralised = (el: Element) => el.matches(NEUTRALISER);
|
const neutralized = (el: Element) => el.matches(NEUTRALIZER);
|
||||||
|
|
||||||
it("keeps a coloured button and drops the white sheet around it", () => {
|
it("keeps a colored button and drops the white sheet around it", () => {
|
||||||
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
|
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
|
||||||
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
|
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
|
||||||
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
|
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
|
||||||
@@ -127,7 +127,7 @@ describe("markKeptSurfaces", () => {
|
|||||||
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
|
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("neutralises a light panel nested inside a dark painted card", () => {
|
it("neutralizes a light panel nested inside a dark painted card", () => {
|
||||||
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
|
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
|
||||||
// are dark enough to be marked, with light content tables inside them.
|
// are dark enough to be marked, with light content tables inside them.
|
||||||
// Those tables used to inherit the card's exemption and render as beige
|
// Those tables used to inherit the card's exemption and render as beige
|
||||||
@@ -153,11 +153,11 @@ describe("markKeptSurfaces", () => {
|
|||||||
// The fix, stated the way the reader experiences it: the nested sheet is
|
// The fix, stated the way the reader experiences it: the nested sheet is
|
||||||
// themed, and so is the copy inside it. Before #310 both were exempt for
|
// themed, and so is the copy inside it. Before #310 both were exempt for
|
||||||
// being descendants of the card.
|
// being descendants of the card.
|
||||||
expect(neutralised(nested)).toBe(true);
|
expect(neutralized(nested)).toBe(true);
|
||||||
expect(neutralised(d.querySelector("td")!)).toBe(true);
|
expect(neutralized(d.querySelector("td")!)).toBe(true);
|
||||||
// The card itself is still left alone, and the page surround still goes.
|
// The card itself is still left alone, and the page surround still goes.
|
||||||
expect(neutralised(card)).toBe(false);
|
expect(neutralized(card)).toBe(false);
|
||||||
expect(neutralised(surround)).toBe(true);
|
expect(neutralized(surround)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still keeps a button that sits inside a nested light panel", () => {
|
it("still keeps a button that sits inside a nested light panel", () => {
|
||||||
@@ -172,11 +172,11 @@ describe("markKeptSurfaces", () => {
|
|||||||
'</div>',
|
'</div>',
|
||||||
);
|
);
|
||||||
expect(markKeptSurfaces(d)).toBe(2);
|
expect(markKeptSurfaces(d)).toBe(2);
|
||||||
expect(neutralised(d.querySelector("table")!)).toBe(true);
|
expect(neutralized(d.querySelector("table")!)).toBe(true);
|
||||||
expect(neutralised(d.querySelector("td")!)).toBe(false);
|
expect(neutralized(d.querySelector("td")!)).toBe(false);
|
||||||
// The label keeps its white, which is the thing #294 bought and this must
|
// The label keeps its white, which is the thing #294 bought and this must
|
||||||
// not spend.
|
// not spend.
|
||||||
expect(neutralised(d.querySelector("a")!)).toBe(false);
|
expect(neutralized(d.querySelector("a")!)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves no light panel exempt across the whole reported specimen", () => {
|
it("leaves no light panel exempt across the whole reported specimen", () => {
|
||||||
@@ -201,7 +201,7 @@ describe("markKeptSurfaces", () => {
|
|||||||
expect(panels.length).toBe(21);
|
expect(panels.length).toBe(21);
|
||||||
|
|
||||||
expect(markKeptSurfaces(d)).toBe(7);
|
expect(markKeptSurfaces(d)).toBe(7);
|
||||||
expect(panels.filter((p) => !neutralised(p))).toHaveLength(0);
|
expect(panels.filter((p) => !neutralized(p))).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads an inline background as well as the attribute", () => {
|
it("reads an inline background as well as the attribute", () => {
|
||||||
@@ -233,7 +233,7 @@ describe("markKeptSurfaces", () => {
|
|||||||
* The control that actually stops it is layout containment on an ancestor of
|
* The control that actually stops it is layout containment on an ancestor of
|
||||||
* the shadow host, which mail CSS has no selector for; that lives in app.css
|
* the shadow host, which mail CSS has no selector for; that lives in app.css
|
||||||
* and is asserted at the bottom of this file, because jsdom does no layout and
|
* and is asserted at the bottom of this file, because jsdom does no layout and
|
||||||
* cannot prove it here. These cover the second line of defence.
|
* cannot prove it here. These cover the second line of defense.
|
||||||
*/
|
*/
|
||||||
describe("mail CSS cannot climb out of its card", () => {
|
describe("mail CSS cannot climb out of its card", () => {
|
||||||
const render = (html: string) => sanitizeEmailHtml(html).html;
|
const render = (html: string) => sanitizeEmailHtml(html).html;
|
||||||
@@ -271,7 +271,7 @@ describe("mail CSS cannot climb out of its card", () => {
|
|||||||
describe("the containment that mail CSS cannot override", () => {
|
describe("the containment that mail CSS cannot override", () => {
|
||||||
it("is still applied to the message body container", async () => {
|
it("is still applied to the message body container", async () => {
|
||||||
// jsdom does no layout, so this asserts the control is present rather than
|
// jsdom does no layout, so this asserts the control is present rather than
|
||||||
// that it works; the behaviour was verified in a real browser. Without it,
|
// that it works; the behavior was verified in a real browser. Without it,
|
||||||
// a message can cover the viewport regardless of what the sanitizer does.
|
// a message can cover the viewport regardless of what the sanitizer does.
|
||||||
const { readFile } = await import("node:fs/promises");
|
const { readFile } = await import("node:fs/promises");
|
||||||
const { join } = await import("node:path");
|
const { join } = await import("node:path");
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("deciding whether a message has an HTML alternative", () => {
|
|||||||
expect(hasHtmlAlternative({ type: "text/htmlish" }, "<p>Hi</p>")).toBe(false);
|
expect(hasHtmlAlternative({ type: "text/htmlish" }, "<p>Hi</p>")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches the type case-insensitively, since a header may be capitalised", () => {
|
it("matches the type case-insensitively, since a header may be capitalized", () => {
|
||||||
expect(hasHtmlAlternative({ type: "TEXT/HTML" }, "<p>Hi</p>")).toBe(true);
|
expect(hasHtmlAlternative({ type: "TEXT/HTML" }, "<p>Hi</p>")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ describe("t", () => {
|
|||||||
expect(currentLanguage()).toBe("en");
|
expect(currentLanguage()).toBe("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("translates once a catalogue is in force", () => {
|
it("translates once a catalog is in force", () => {
|
||||||
setCatalog("de", de);
|
setCatalog("de", de);
|
||||||
expect(t("Archive")).toBe("Archivieren");
|
expect(t("Archive")).toBe("Archivieren");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back per string, not per catalogue", () => {
|
it("falls back per string, not per catalog", () => {
|
||||||
setCatalog("de", de);
|
setCatalog("de", de);
|
||||||
expect(t("Report spam")).toBe("Report spam");
|
expect(t("Report spam")).toBe("Report spam");
|
||||||
});
|
});
|
||||||
@@ -60,7 +60,7 @@ describe("interpolation", () => {
|
|||||||
describe("plural", () => {
|
describe("plural", () => {
|
||||||
const FORMS = { one: "{n} message", other: "{n} messages" };
|
const FORMS = { one: "{n} message", other: "{n} messages" };
|
||||||
|
|
||||||
it("picks the English form without a catalogue", () => {
|
it("picks the English form without a catalog", () => {
|
||||||
expect(plural(1, FORMS)).toBe("1 message");
|
expect(plural(1, FORMS)).toBe("1 message");
|
||||||
expect(plural(0, FORMS)).toBe("0 messages");
|
expect(plural(0, FORMS)).toBe("0 messages");
|
||||||
expect(plural(5, FORMS)).toBe("5 messages");
|
expect(plural(5, FORMS)).toBe("5 messages");
|
||||||
@@ -73,7 +73,7 @@ describe("plural", () => {
|
|||||||
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
|
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to `other` when the catalogue lacks the category", () => {
|
it("falls back to `other` when the catalog lacks the category", () => {
|
||||||
setCatalog("de", de);
|
setCatalog("de", de);
|
||||||
// German has no "few"; asking for 3 must not render undefined.
|
// German has no "few"; asking for 3 must not render undefined.
|
||||||
expect(plural(3, FORMS)).toBe("3 Nachrichten");
|
expect(plural(3, FORMS)).toBe("3 Nachrichten");
|
||||||
@@ -95,7 +95,7 @@ describe("tNode", () => {
|
|||||||
|
|
||||||
it("lets a translator move the element", () => {
|
it("lets a translator move the element", () => {
|
||||||
// Splitting the sentence into two t() calls could not do this: the
|
// Splitting the sentence into two t() calls could not do this: the
|
||||||
// fragments would render in the English order whatever the catalogue said.
|
// fragments would render in the English order whatever the catalog said.
|
||||||
setCatalog("de", de);
|
setCatalog("de", de);
|
||||||
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
|
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
|
||||||
.toBe("<code>mailto:</code>-Links hier öffnen");
|
.toBe("<code>mailto:</code>-Links hier öffnen");
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ describe("parseIcsDuration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("looksLikeCalendar", () => {
|
describe("looksLikeCalendar", () => {
|
||||||
it("recognises a calendar and rejects an error page", () => {
|
it("recognizes a calendar and rejects an error page", () => {
|
||||||
expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true);
|
expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true);
|
||||||
expect(looksLikeCalendar("<!doctype html><title>404</title>")).toBe(false);
|
expect(looksLikeCalendar("<!doctype html><title>404</title>")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const find = (e: JSCalendarEvent[], prefix: string) => eventLines(e).filter((l)
|
|||||||
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
|
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
|
||||||
|
|
||||||
describe("the document around the events", () => {
|
describe("the document around the events", () => {
|
||||||
it("is a calendar a reader will recognise", () => {
|
it("is a calendar a reader will recognize", () => {
|
||||||
const l = lines([base]);
|
const l = lines([base]);
|
||||||
expect(l[0]).toBe("BEGIN:VCALENDAR");
|
expect(l[0]).toBe("BEGIN:VCALENDAR");
|
||||||
expect(l).toContain("VERSION:2.0");
|
expect(l).toContain("VERSION:2.0");
|
||||||
@@ -105,7 +105,7 @@ describe("recurrence", () => {
|
|||||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
|
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("turns a cancelled occurrence into an EXDATE", () => {
|
it("turns a canceled occurrence into an EXDATE", () => {
|
||||||
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
|
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
|
||||||
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
|
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
|
||||||
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
|
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
|
||||||
@@ -166,7 +166,7 @@ describe("the rest of an event", () => {
|
|||||||
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
|
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes the organiser and the guests, with what each answered", () => {
|
it("writes the organizer and the guests, with what each answered", () => {
|
||||||
const e = {
|
const e = {
|
||||||
...base,
|
...base,
|
||||||
organizerCalendarAddress: "mailto:[email protected]",
|
organizerCalendarAddress: "mailto:[email protected]",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe("resolveUiLanguage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("refuses a language whose strings are not shipped", () => {
|
it("refuses a language whose strings are not shipped", () => {
|
||||||
// The account travels between machines and can outlive a catalogue. A
|
// The account travels between machines and can outlive a catalog. A
|
||||||
// page that says lang="fr" while rendering English is worse than one that
|
// page that says lang="fr" while rendering English is worse than one that
|
||||||
// admits to English: it stops the reader translating it themselves.
|
// admits to English: it stops the reader translating it themselves.
|
||||||
// Derived rather than named, so shipping another language does not turn
|
// Derived rather than named, so shipping another language does not turn
|
||||||
@@ -30,7 +30,7 @@ describe("resolveUiLanguage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("carries the Beta flag until a person has signed the language off", () => {
|
it("carries the Beta flag until a person has signed the language off", () => {
|
||||||
// Not a completeness measure. A catalogue can be word-for-word finished
|
// Not a completeness measure. A catalog can be word-for-word finished
|
||||||
// and still read like a machine wrote it, which is what this marks.
|
// and still read like a machine wrote it, which is what this marks.
|
||||||
// Every shipped language except English is unreviewed, and stays marked
|
// Every shipped language except English is unreviewed, and stays marked
|
||||||
// until a person says otherwise.
|
// until a person says otherwise.
|
||||||
@@ -40,12 +40,12 @@ describe("resolveUiLanguage", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honours one that is", () => {
|
it("honors one that is", () => {
|
||||||
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only offers languages that resolve to themselves", () => {
|
it("only offers languages that resolve to themselves", () => {
|
||||||
// Guards the ordering mistake: adding a picker entry before its catalogue.
|
// Guards the ordering mistake: adding a picker entry before its catalog.
|
||||||
for (const l of UI_LANGUAGES) {
|
for (const l of UI_LANGUAGES) {
|
||||||
expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
||||||
expect(l.name.trim()).not.toBe("");
|
expect(l.name.trim()).not.toBe("");
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe("comparatorsFor, custom levels", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("optional sorts, which a server is allowed to refuse", () => {
|
describe("optional sorts, which a server is allowed to refuse", () => {
|
||||||
it("recognises the keyword properties", () => {
|
it("recognizes the keyword properties", () => {
|
||||||
expect(isOptionalSort({ property: "hasKeyword", keyword: "$seen" })).toBe(true);
|
expect(isOptionalSort({ property: "hasKeyword", keyword: "$seen" })).toBe(true);
|
||||||
expect(isOptionalSort({ property: "someInThreadHaveKeyword", keyword: "$flagged" })).toBe(true);
|
expect(isOptionalSort({ property: "someInThreadHaveKeyword", keyword: "$flagged" })).toBe(true);
|
||||||
expect(isOptionalSort({ property: "receivedAt" })).toBe(false);
|
expect(isOptionalSort({ property: "receivedAt" })).toBe(false);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
|
import { isLocalizedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
|
||||||
import { setCatalog, type Catalog } from "@/lib/i18n";
|
import { setCatalog, type Catalog } from "@/lib/i18n";
|
||||||
import type { Mailbox } from "@/jmap/types";
|
import type { Mailbox } from "@/jmap/types";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const mb = (id: string, name: string, role: string | null = null, parentId: stri
|
|||||||
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
|
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
|
||||||
|
|
||||||
describe("mailboxDisplayName", () => {
|
describe("mailboxDisplayName", () => {
|
||||||
it("is the server's name until a catalogue says otherwise", () => {
|
it("is the server's name until a catalog says otherwise", () => {
|
||||||
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
|
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,18 +43,18 @@ describe("mailboxDisplayName", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isLocalisedName", () => {
|
describe("isLocalizedName", () => {
|
||||||
it("tells an editor when the name on screen is not the server's", () => {
|
it("tells an editor when the name on screen is not the server's", () => {
|
||||||
// A rename box prefilled with "Papierkorb" would rename the folder to that
|
// A rename box prefilled with "Papierkorb" would rename the folder to that
|
||||||
// the moment somebody pressed Save — a real change made by accident.
|
// the moment somebody pressed Save — a real change made by accident.
|
||||||
expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true);
|
expect(isLocalizedName(mb("1", "Deleted Items", "trash"))).toBe(true);
|
||||||
expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false);
|
expect(isLocalizedName(mb("2", "Newsletters"))).toBe(false);
|
||||||
expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false);
|
expect(isLocalizedName(mb("3", "Work", "subscribed"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mailboxDisplayPath", () => {
|
describe("mailboxDisplayPath", () => {
|
||||||
it("localises each part that has a role and leaves the rest", () => {
|
it("localizes each part that has a role and leaves the rest", () => {
|
||||||
setCatalog("de", de);
|
setCatalog("de", de);
|
||||||
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
|
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
|
||||||
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
|
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe("renderMarkdown", () => {
|
|||||||
/*
|
/*
|
||||||
* Markdown passes raw HTML through by design, and the file came from
|
* Markdown passes raw HTML through by design, and the file came from
|
||||||
* somewhere else -- an upload, or a share from another account. Every one of
|
* somewhere else -- an upload, or a share from another account. Every one of
|
||||||
* these renders as a script tag without a sanitiser.
|
* these renders as a script tag without a sanitizer.
|
||||||
*/
|
*/
|
||||||
it("takes out anything that would execute", () => {
|
it("takes out anything that would execute", () => {
|
||||||
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
|
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe("the rest of the schema", () => {
|
|||||||
expect(phones).toContainEqual(expect.objectContaining({ number: "3", features: { pager: true } }));
|
expect(phones).toContainEqual(expect.objectContaining({ number: "3", features: { pager: true } }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the organisation, its units and the job title", () => {
|
it("reads the organization, its units and the job title", () => {
|
||||||
const c = card("dn: cn=X\ncn: X\no: Example Corp\nou: Research\nou: Optics\ntitle: Lens Grinder\n")!;
|
const c = card("dn: cn=X\ncn: X\no: Example Corp\nou: Research\nou: Optics\ntitle: Lens Grinder\n")!;
|
||||||
expect(values(c.organizations)[0]).toMatchObject({
|
expect(values(c.organizations)[0]).toMatchObject({
|
||||||
name: "Example Corp",
|
name: "Example Corp",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe("the palettes themselves", () => {
|
|||||||
it("has a light and a dark half for every one of them", () => {
|
it("has a light and a dark half for every one of them", () => {
|
||||||
// The reason there is no "this palette is dark only" machinery: there is
|
// The reason there is no "this palette is dark only" machinery: there is
|
||||||
// no such palette. ihasmail's own gained a light half, and the override,
|
// no such palette. ihasmail's own gained a light half, and the override,
|
||||||
// the toggle's memory and a greyed-out control all went with it.
|
// the toggle's memory and a grayed-out control all went with it.
|
||||||
expect(PALETTES.map((p) => p.id)).toEqual([
|
expect(PALETTES.map((p) => p.id)).toEqual([
|
||||||
"default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night",
|
"default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night",
|
||||||
"catppuccin", "solarized", "ayu", "kanagawa", "everforest", "primer",
|
"catppuccin", "solarized", "ayu", "kanagawa", "everforest", "primer",
|
||||||
@@ -75,7 +75,7 @@ describe("legacyTheme, read by a device still on an older build", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("toggleTarget", () => {
|
describe("toggleTarget", () => {
|
||||||
it("flips the mode and keeps the colours, whatever the palette", () => {
|
it("flips the mode and keeps the colors, whatever the palette", () => {
|
||||||
for (const palette of ["default", "ihasmail", "gruvbox", "dracula", "rose-pine", "tokyo-night"] as const) {
|
for (const palette of ["default", "ihasmail", "gruvbox", "dracula", "rose-pine", "tokyo-night"] as const) {
|
||||||
expect(toggleTarget({ palette, mode: "dark" }, false)).toEqual({ palette, mode: "light" });
|
expect(toggleTarget({ palette, mode: "dark" }, false)).toEqual({ palette, mode: "light" });
|
||||||
expect(toggleTarget({ palette, mode: "light" }, false)).toEqual({ palette, mode: "dark" });
|
expect(toggleTarget({ palette, mode: "light" }, false)).toEqual({ palette, mode: "dark" });
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("permission labels", () => {
|
|||||||
* does not: a missing one would show English in the middle of a translated
|
* does not: a missing one would show English in the middle of a translated
|
||||||
* picker, and a stale one would never be looked up.
|
* picker, and a stale one would never be looked up.
|
||||||
*/
|
*/
|
||||||
describe("the permission catalogues", () => {
|
describe("the permission catalogs", () => {
|
||||||
const modules = import.meta.glob<{ permissionCatalog: PermissionCatalog }>("../../locales/permissions/*.ts");
|
const modules = import.meta.glob<{ permissionCatalog: PermissionCatalog }>("../../locales/permissions/*.ts");
|
||||||
const tagOf = (path: string) => path.split("/").pop()!.replace(/\.ts$/, "");
|
const tagOf = (path: string) => path.split("/").pop()!.replace(/\.ts$/, "");
|
||||||
const languages = UI_LANGUAGES.map((l) => l.tag).filter((tag) => tag !== "en");
|
const languages = UI_LANGUAGES.map((l) => l.tag).filter((tag) => tag !== "en");
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
|
|||||||
* Settings used to live only in localStorage, so nothing followed the user
|
* Settings used to live only in localStorage, so nothing followed the user
|
||||||
* between devices — issue #54, whose sharpest case is the default identity:
|
* between devices — issue #54, whose sharpest case is the default identity:
|
||||||
* with none set, the address that sorts first wins, so mail goes out from an
|
* with none set, the address that sorts first wins, so mail goes out from an
|
||||||
* address the recipient may not recognise.
|
* address the recipient may not recognize.
|
||||||
*
|
*
|
||||||
* The split is written as a list of exceptions, which means the interesting
|
* The split is written as a list of exceptions, which means the interesting
|
||||||
* test is not "does this key sync" but "does a key added later sync without
|
* test is not "does this key sync" but "does a key added later sync without
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ describe("sharing a file", () => {
|
|||||||
await expect(shareFile(aFile())).resolves.toBe("unsupported");
|
await expect(shareFile(aFile())).resolves.toBe("unsupported");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("raises anything it does not recognise, so a real fault is still reported", async () => {
|
it("raises anything it does not recognize, so a real fault is still reported", async () => {
|
||||||
stubNavigator({
|
stubNavigator({
|
||||||
share: vi.fn(async () => { throw new DOMException("boom", "DataError"); }),
|
share: vi.fn(async () => { throw new DOMException("boom", "DataError"); }),
|
||||||
canShare: (() => true) as unknown as Navigator["canShare"],
|
canShare: (() => true) as unknown as Navigator["canShare"],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { buildMarkerSignature, byteLength, compactHtml, markerOf, signatureTooLong, SIGNATURE_LIMIT } from "../signatureHtml";
|
import { buildMarkerSignature, byteLength, compactHtml, markerOf, signatureTooLong, SIGNATURE_LIMIT } from "../signatureHtml";
|
||||||
|
|
||||||
describe("signature compaction", () => {
|
describe("signature compaction", () => {
|
||||||
it("strips office cruft and non-essential styles but keeps colours and links", () => {
|
it("strips office cruft and non-essential styles but keeps colors and links", () => {
|
||||||
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Coffey</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
|
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Coffey</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
|
||||||
const out = compactHtml(src);
|
const out = compactHtml(src);
|
||||||
expect(out).not.toContain("mso-");
|
expect(out).not.toContain("mso-");
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { catalog as de } from "@/locales/de";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The briefing is the only thing standing between a notification action and a
|
* 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
|
* button labeled in a language the reader does not use — the worker is plain
|
||||||
* JavaScript outside the bundle and cannot reach a catalogue.
|
* JavaScript outside the bundle and cannot reach a catalog.
|
||||||
*
|
*
|
||||||
* It is also the only place the archive mailbox is named, and getting that
|
* 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
|
* wrong does not fail visibly: a message would be filed somewhere, just not
|
||||||
@@ -45,7 +45,7 @@ describe("the worker's briefing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("carries the worker's text in the language the tab is in", async () => {
|
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
|
// The worker has no catalog. Everything it will say has to be said here
|
||||||
// first, or a German reader gets English buttons on their lock screen.
|
// first, or a German reader gets English buttons on their lock screen.
|
||||||
setCatalog("de", de);
|
setCatalog("de", de);
|
||||||
const { store } = fakeCaches();
|
const { store } = fakeCaches();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
|
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A swipe names what it is about to do on a coloured strip the reader sees for
|
* A swipe names what it is about to do on a colored strip the reader sees for
|
||||||
* about a third of a second before letting go. These check that the name is
|
* about a third of a second before letting go. These check that the name is
|
||||||
* true in the folder it is being read in — which is the whole reason the
|
* true in the folder it is being read in — which is the whole reason the
|
||||||
* descriptor exists rather than a fixed label per setting.
|
* descriptor exists rather than a fixed label per setting.
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ describe("remembering the palette you were on", () => {
|
|||||||
expect(toggleTarget(away, false)).toEqual({ palette: "ihasmail", mode: "dark" });
|
expect(toggleTarget(away, false)).toEqual({ palette: "ihasmail", mode: "dark" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the colours when the palette has both sides", () => {
|
it("keeps the colors when the palette has both sides", () => {
|
||||||
const away = toggleTarget({ palette: "gruvbox", mode: "dark" }, false);
|
const away = toggleTarget({ palette: "gruvbox", mode: "dark" }, false);
|
||||||
expect(away.palette).toBe("gruvbox");
|
expect(away.palette).toBe("gruvbox");
|
||||||
expect(away.mode).toBe("light");
|
expect(away.mode).toBe("light");
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [
|
|||||||
const text = (a: Uint8Array) => new TextDecoder().decode(a);
|
const text = (a: Uint8Array) => new TextDecoder().decode(a);
|
||||||
|
|
||||||
describe("isTnef", () => {
|
describe("isTnef", () => {
|
||||||
it("recognises the types and the filename", () => {
|
it("recognizes the types and the filename", () => {
|
||||||
expect(isTnef("application/ms-tnef", null)).toBe(true);
|
expect(isTnef("application/ms-tnef", null)).toBe(true);
|
||||||
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
|
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
|
||||||
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
|
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("lockAxis", () => {
|
|||||||
expect(lockAxis(30, 25)).toBe("y");
|
expect(lockAxis(30, 25)).toBe("y");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("counts distance on either axis towards committing", () => {
|
it("counts distance on either axis toward committing", () => {
|
||||||
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
|
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
|
||||||
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
|
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
/*
|
/*
|
||||||
* The guard's answers, and which of them the dialog leans on.
|
* The guard's answers, and which of them the dialog leans on.
|
||||||
*
|
*
|
||||||
* It shipped with "Discard changes" as the only choice carrying a colour, which
|
* It shipped with "Discard changes" as the only choice carrying a color, which
|
||||||
* made losing the work the easy thing to click on a dialog whose entire purpose
|
* made losing the work the easy thing to click on a dialog whose entire purpose
|
||||||
* is to stop that (#175). The emphasis belongs on the safe answer; the
|
* is to stop that (#175). The emphasis belongs on the safe answer; the
|
||||||
* destructive one stays legible as destructive without being the loudest thing
|
* destructive one stays legible as destructive without being the loudest thing
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const advertises = (account: AccountLike | undefined, cap: string): boolean =>
|
|||||||
Boolean(account && cap in (account.accountCapabilities ?? {}));
|
Boolean(account && cap in (account.accountCapabilities ?? {}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The account to read and write for this capability, honouring the switcher.
|
* The account to read and write for this capability, honoring the switcher.
|
||||||
*
|
*
|
||||||
* Use for anything the reader is looking at: their mail, a shared calendar,
|
* Use for anything the reader is looking at: their mail, a shared calendar,
|
||||||
* somebody's files. Not for anything of the reader's own — see below.
|
* somebody's files. Not for anything of the reader's own — see below.
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export interface RoleDef {
|
|||||||
* more rights than its own and sign in as it. ihasmail refuses to offer that,
|
* more rights than its own and sign in as it. ihasmail refuses to offer that,
|
||||||
* and treats such an account as read-only.
|
* and treats such an account as read-only.
|
||||||
*
|
*
|
||||||
* It errs towards refusing. A role that cannot be read -- the viewer lacks
|
* It errs toward refusing. A role that cannot be read -- the viewer lacks
|
||||||
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
|
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
|
||||||
* an unknown grant is not a grant the viewer can be shown to hold. What it
|
* an unknown grant is not a grant the viewer can be shown to hold. What it
|
||||||
* cannot see is tenancy: an "Administrator" account is a tenant administrator
|
* cannot see is tenancy: an "Administrator" account is a tenant administrator
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface MessageStats {
|
|||||||
recorded: boolean;
|
recorded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summariseMetrics(records: readonly MetricRecord[]): MessageStats {
|
export function summarizeMetrics(records: readonly MetricRecord[]): MessageStats {
|
||||||
let received = 0;
|
let received = 0;
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
let memory: MessageStats["memory"] = null;
|
let memory: MessageStats["memory"] = null;
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export type DirectoryObject = "account" | "domain" | "group" | "list" | "role" |
|
|||||||
* Stalwart explains a refusal in English, and its words are never shown as
|
* Stalwart explains a refusal in English, and its words are never shown as
|
||||||
* they are: an interface in German that answers in English reads as broken
|
* they are: an interface in German that answers in English reads as broken
|
||||||
* even when the English is exact. Every type the registry returns has its
|
* even when the English is exact. Every type the registry returns has its
|
||||||
* own message, and a value a validator refused is recognised by the
|
* own message, and a value a validator refused is recognized by the
|
||||||
* validator's wording and said again here.
|
* validator's wording and said again here.
|
||||||
*
|
*
|
||||||
* One exception, on purpose. A password policy is the server's to set -- a
|
* One exception, on purpose. A password policy is the server's to set -- a
|
||||||
@@ -282,16 +282,16 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
|||||||
return t("One of the chosen domain, role or group can't be used for this account.");
|
return t("One of the chosen domain, role or group can't be used for this account.");
|
||||||
case "overQuota":
|
case "overQuota":
|
||||||
return object === "domain"
|
return object === "domain"
|
||||||
? t("Your organisation has reached the number of domains it is allowed.")
|
? t("Your organization has reached the number of domains it is allowed.")
|
||||||
: object === "group"
|
: object === "group"
|
||||||
? t("Your organisation has reached the number of groups it is allowed.")
|
? t("Your organization has reached the number of groups it is allowed.")
|
||||||
: object === "list"
|
: object === "list"
|
||||||
? t("Your organisation has reached the number of mailing lists it is allowed.")
|
? t("Your organization has reached the number of mailing lists it is allowed.")
|
||||||
: object === "role"
|
: object === "role"
|
||||||
? t("Your organisation has reached the number of roles it is allowed.")
|
? t("Your organization has reached the number of roles it is allowed.")
|
||||||
: object === "tenant"
|
: object === "tenant"
|
||||||
? t("The server allows no more tenants.")
|
? t("The server allows no more tenants.")
|
||||||
: t("Your organisation has reached the number of accounts it is allowed.");
|
: t("Your organization has reached the number of accounts it is allowed.");
|
||||||
case "objectIsLinked":
|
case "objectIsLinked":
|
||||||
return t("Something still depends on this, so the server kept it.");
|
return t("Something still depends on this, so the server kept it.");
|
||||||
case "notFound":
|
case "notFound":
|
||||||
|
|||||||
@@ -127,18 +127,18 @@ export async function namesOf(object: "Tenant" | "DnsServer", ids: string[]): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Lower-case, no surrounding space or root dot: how a domain is written back. */
|
/** Lower-case, no surrounding space or root dot: how a domain is written back. */
|
||||||
export function normaliseDomain(name: string): string {
|
export function normalizeDomain(name: string): string {
|
||||||
return name.trim().toLowerCase().replace(/\.$/, "");
|
return name.trim().toLowerCase().replace(/\.$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enough of a check to catch a typo before the server does; the server decides. */
|
/** Enough of a check to catch a typo before the server does; the server decides. */
|
||||||
export function looksLikeDomain(name: string): boolean {
|
export function looksLikeDomain(name: string): boolean {
|
||||||
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(normaliseDomain(name));
|
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(normalizeDomain(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createDomain(input: { name: string; description: string }): Promise<string> {
|
export async function createDomain(input: { name: string; description: string }): Promise<string> {
|
||||||
const res = await client.call<SetResponse>("x:Domain/set", {
|
const res = await client.call<SetResponse>("x:Domain/set", {
|
||||||
create: { n: { name: normaliseDomain(input.name), description: input.description.trim() || null } },
|
create: { n: { name: normalizeDomain(input.name), description: input.description.trim() || null } },
|
||||||
});
|
});
|
||||||
refused(res, "notCreated");
|
refused(res, "notCreated");
|
||||||
const id = (res.created?.n as { id?: string } | undefined)?.id;
|
const id = (res.created?.n as { id?: string } | undefined)?.id;
|
||||||
@@ -179,8 +179,8 @@ export interface DnsRecord {
|
|||||||
/**
|
/**
|
||||||
* Read the zone file Stalwart computes for a domain.
|
* Read the zone file Stalwart computes for a domain.
|
||||||
*
|
*
|
||||||
* Its serialiser writes one record per line as `name IN TYPE value`, and a TXT
|
* Its serializer writes one record per line as `name IN TYPE value`, and a TXT
|
||||||
* record longer than 255 bytes as a parenthesised run of quoted strings, one
|
* record longer than 255 bytes as a parenthesized run of quoted strings, one
|
||||||
* per line. A DNS provider's form wants the whole value, so the strings are
|
* per line. A DNS provider's form wants the whole value, so the strings are
|
||||||
* joined and unescaped; the original lines are kept for anyone pasting into a
|
* joined and unescaped; the original lines are kept for anyone pasting into a
|
||||||
* zone. Anything that does not parse is kept too, as its own row, rather than
|
* zone. Anything that does not parse is kept too, as its own row, rather than
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export async function loadRoleDefaults(): Promise<RoleDefaults | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stalwart's labelled permission list, through ihasmail's server. */
|
/** Stalwart's labeled permission list, through ihasmail's server. */
|
||||||
export async function loadPermissionList(): Promise<PermissionInfo[]> {
|
export async function loadPermissionList(): Promise<PermissionInfo[]> {
|
||||||
const res = await apiFetch<{ permissions: PermissionInfo[] }>("/api/admin/permissions");
|
const res = await apiFetch<{ permissions: PermissionInfo[] }>("/api/admin/permissions");
|
||||||
return res.permissions;
|
return res.permissions;
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ function bodyText(email: Email): string {
|
|||||||
* Everyone the message was between, as guests: the sender and the people it
|
* Everyone the message was between, as guests: the sender and the people it
|
||||||
* was addressed to.
|
* was addressed to.
|
||||||
*
|
*
|
||||||
* The reader's own addresses come out -- they are the organiser, and an
|
* The reader's own addresses come out -- they are the organizer, and an
|
||||||
* organiser listed among their own guests is an event that invites you to your
|
* organizer listed among their own guests is an event that invites you to your
|
||||||
* own appointment. Bcc stays out too, on a message the reader sent themselves:
|
* own appointment. Bcc stays out too, on a message the reader sent themselves:
|
||||||
* a blind recipient added to a guest list is visible to every other guest, and
|
* a blind recipient added to a guest list is visible to every other guest, and
|
||||||
* turning a hidden copy into a public one is not something a menu item should
|
* turning a hidden copy into a public one is not something a menu item should
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* `import.meta.env.BASE_URL` is Vite's own copy of the `base` it built with,
|
* `import.meta.env.BASE_URL` is Vite's own copy of the `base` it built with,
|
||||||
* and `vite.config.ts` sets that from `BASE_PATH` through the shared
|
* and `vite.config.ts` sets that from `BASE_PATH` through the shared
|
||||||
* normaliser -- so this is the same answer the server reached, not a second
|
* normalizer -- so this is the same answer the server reached, not a second
|
||||||
* guess at it. Reading it here rather than re-deriving it from
|
* guess at it. Reading it here rather than re-deriving it from
|
||||||
* `window.location` matters because the app is a SPA: at `/mail/inbox/abc`
|
* `window.location` matters because the app is a SPA: at `/mail/inbox/abc`
|
||||||
* there is nothing in the address that says how much of it is the mount.
|
* there is nothing in the address that says how much of it is the mount.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface Birthday {
|
|||||||
age: number | null;
|
age: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The prefix marking a synthesised event, so nothing tries to save one. */
|
/** The prefix marking a synthesized event, so nothing tries to save one. */
|
||||||
export const BIRTHDAY_ID_PREFIX = "ihm-birthday:";
|
export const BIRTHDAY_ID_PREFIX = "ihm-birthday:";
|
||||||
|
|
||||||
/** The virtual calendar's id. Not a JMAP id, and deliberately unlike one. */
|
/** The virtual calendar's id. Not a JMAP id, and deliberately unlike one. */
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function withPrefs<T>(over: Partial<DateTimePrefs>, fn: () => T): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Locale reported by Stalwart for this account (normalised), or null. */
|
/** Locale reported by Stalwart for this account (normalized), or null. */
|
||||||
export function setServerLocale(raw: string | null | undefined): void {
|
export function setServerLocale(raw: string | null | undefined): void {
|
||||||
serverLocale = normalizeLocale(raw);
|
serverLocale = normalizeLocale(raw);
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ function num(value: number, digits: number): string {
|
|||||||
return f.format(value);
|
return f.format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Time-of-day options honouring the 12h/24h preference. */
|
/** Time-of-day options honoring the 12h/24h preference. */
|
||||||
export function timeOptions(): Intl.DateTimeFormatOptions {
|
export function timeOptions(): Intl.DateTimeFormatOptions {
|
||||||
switch (prefs.timeFormat) {
|
switch (prefs.timeFormat) {
|
||||||
case "24":
|
case "24":
|
||||||
@@ -562,13 +562,13 @@ export function localeOptions(): LocaleOption[] {
|
|||||||
* Weekday names in the reader's locale, indexed by JSCalendar's two-letter day.
|
* Weekday names in the reader's locale, indexed by JSCalendar's two-letter day.
|
||||||
*
|
*
|
||||||
* These used to be a table of English strings with a `short` of "M", "T", "W"…
|
* These used to be a table of English strings with a `short` of "M", "T", "W"…
|
||||||
* which could not become catalogue entries at all: "T" is both Tuesday and
|
* which could not become catalog entries at all: "T" is both Tuesday and
|
||||||
* Thursday and "S" is both Saturday and Sunday, so the key collides with
|
* Thursday and "S" is both Saturday and Sunday, so the key collides with
|
||||||
* itself. A catalogue cannot hold two translations under one key, and no
|
* itself. A catalog cannot hold two translations under one key, and no
|
||||||
* amount of translating fixes that — the data was wrong, not the wiring.
|
* amount of translating fixes that — the data was wrong, not the wiring.
|
||||||
*
|
*
|
||||||
* Intl has the names already, in every locale, in three widths, and gets the
|
* Intl has the names already, in every locale, in three widths, and gets the
|
||||||
* plural and capitalisation conventions right without anybody maintaining a
|
* plural and capitalization conventions right without anybody maintaining a
|
||||||
* list. 2026-06-01 is a Monday; the rest follow from it.
|
* list. 2026-06-01 is a Monday; the rest follow from it.
|
||||||
*/
|
*/
|
||||||
export type WeekdayKey = "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
|
export type WeekdayKey = "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function unsafe(ch: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Long enough to stay recognisable, short enough to survive a 255-*byte* limit
|
* Long enough to stay recognizable, short enough to survive a 255-*byte* limit
|
||||||
* once a CJK subject is three bytes a character.
|
* once a CJK subject is three bytes a character.
|
||||||
*/
|
*/
|
||||||
const MAX = 80;
|
const MAX = 80;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function isShared(node: Pick<FileNode, "shareWith">): boolean {
|
|||||||
* legal moves behind a disabled drop. The server refuses those with a message
|
* legal moves behind a disabled drop. The server refuses those with a message
|
||||||
* of its own, which is a better answer than a silent one.
|
* of its own, which is a better answer than a silent one.
|
||||||
*/
|
*/
|
||||||
/** The MIME a dragged node is offered under, so a target can recognise it. */
|
/** The MIME a dragged node is offered under, so a target can recognize it. */
|
||||||
export const NODE_MIME = "application/x-ihasmail-filenode";
|
export const NODE_MIME = "application/x-ihasmail-filenode";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function canMoveFolderTo(mailboxes: Record<Id, Mailbox>, id: Id, targetId
|
|||||||
return targetId === null || Boolean(mailboxes[targetId]?.myRights.mayCreateChild);
|
return targetId === null || Boolean(mailboxes[targetId]?.myRights.mayCreateChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The colour chosen for a folder, if any. Ids are used, so a rename keeps it. */
|
/** The color chosen for a folder, if any. Ids are used, so a rename keeps it. */
|
||||||
export function folderColor(colors: Record<string, string>, id: Id): string | null {
|
export function folderColor(colors: Record<string, string>, id: Id): string | null {
|
||||||
return colors[id] ?? null;
|
return colors[id] ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-16
@@ -50,7 +50,7 @@ function ensureHooks() {
|
|||||||
* convincing fake over the whole app. The control that actually stops that is
|
* convincing fake over the whole app. The control that actually stops that is
|
||||||
* layout containment on an ancestor of the shadow host (see `.message-body` in
|
* layout containment on an ancestor of the shadow host (see `.message-body` in
|
||||||
* app.css), which mail CSS has no selector for. This is the second line:
|
* app.css), which mail CSS has no selector for. This is the second line:
|
||||||
* neutralise the declarations themselves, and defang `:host`, which is how mail
|
* neutralize the declarations themselves, and defang `:host`, which is how mail
|
||||||
* CSS would otherwise reach the host element.
|
* CSS would otherwise reach the host element.
|
||||||
*/
|
*/
|
||||||
function hardenCss(css: string): string {
|
function hardenCss(css: string): string {
|
||||||
@@ -183,7 +183,7 @@ export const EMAIL_BASE_CSS = `
|
|||||||
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
|
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
|
||||||
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
|
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
|
||||||
|
|
||||||
/* "Follow the app theme" — only applied to mail that brings no colours of its
|
/* "Follow the app theme" — only applied to mail that brings no colors of its
|
||||||
own. The custom properties are inherited from the host document, so a theme
|
own. The custom properties are inherited from the host document, so a theme
|
||||||
switch repaints the message without re-rendering it. */
|
switch repaints the message without re-rendering it. */
|
||||||
.ihm-email-root.themed { color: var(--fg, #1f2937); background: var(--bg-elev, #fff); }
|
.ihm-email-root.themed { color: var(--fg, #1f2937); background: var(--bg-elev, #fff); }
|
||||||
@@ -193,7 +193,7 @@ export const EMAIL_BASE_CSS = `
|
|||||||
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
|
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
|
||||||
|
|
||||||
/* "Even mail that styles itself" — the second, opt-in switch, applied on top of
|
/* "Even mail that styles itself" — the second, opt-in switch, applied on top of
|
||||||
.themed. Everything the sender coloured is neutralised except the surfaces
|
.themed. Everything the sender colored is neutralized except the surfaces
|
||||||
marked by markKeptSurfaces() and what it marked as sitting on them, so a
|
marked by markKeptSurfaces() and what it marked as sitting on them, so a
|
||||||
white wrapper table
|
white wrapper table
|
||||||
stops being a bright card while a blue button keeps its white label. The
|
stops being a bright card while a blue button keeps its white label. The
|
||||||
@@ -205,7 +205,7 @@ export const EMAIL_BASE_CSS = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does this message paint itself? Mail that sets a background or text colour
|
* Does this message paint itself? Mail that sets a background or text color
|
||||||
* has a design of its own, and forcing a dark palette on half of it is worse
|
* has a design of its own, and forcing a dark palette on half of it is worse
|
||||||
* than leaving it alone — so those keep the light card they were built for.
|
* than leaving it alone — so those keep the light card they were built for.
|
||||||
*
|
*
|
||||||
@@ -227,7 +227,7 @@ export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
|
|||||||
/* ---------- forcing the theme onto mail that styles itself ---------- */
|
/* ---------- forcing the theme onto mail that styles itself ---------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relative luminance per WCAG 2.x, or `null` when the colour cannot be read.
|
* Relative luminance per WCAG 2.x, or `null` when the color cannot be read.
|
||||||
*
|
*
|
||||||
* Only what actually turns up in mail is parsed: hex in three, six or eight
|
* Only what actually turns up in mail is parsed: hex in three, six or eight
|
||||||
* digits, `rgb()`/`rgba()`, and the handful of names senders still write out.
|
* digits, `rgb()`/`rgba()`, and the handful of names senders still write out.
|
||||||
@@ -263,7 +263,7 @@ export function relativeLuminance(color: string): number | null {
|
|||||||
if (m[4] !== undefined) a = m[4].endsWith("%") ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
|
if (m[4] !== undefined) a = m[4].endsWith("%") ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
|
||||||
}
|
}
|
||||||
if ([r, g, b, a].some((n) => !Number.isFinite(n))) return null;
|
if ([r, g, b, a].some((n) => !Number.isFinite(n))) return null;
|
||||||
// A fully transparent colour paints nothing, whatever its channels say.
|
// A fully transparent color paints nothing, whatever its channels say.
|
||||||
if (a === 0) return null;
|
if (a === 0) return null;
|
||||||
const lin = (c: number) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
|
const lin = (c: number) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
|
||||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||||
@@ -272,7 +272,7 @@ export function relativeLuminance(color: string): number | null {
|
|||||||
/**
|
/**
|
||||||
* Above this, a background is a sheet the message is laid on rather than a
|
* Above this, a background is a sheet the message is laid on rather than a
|
||||||
* thing drawn on top of it. White wrappers sit at 1.0; the blue of a call to
|
* thing drawn on top of it. White wrappers sit at 1.0; the blue of a call to
|
||||||
* action lands near 0.09, mid-grey near 0.22.
|
* action lands near 0.09, mid-gray near 0.22.
|
||||||
*/
|
*/
|
||||||
export const LIGHT_SURFACE_LUMINANCE = 0.5;
|
export const LIGHT_SURFACE_LUMINANCE = 0.5;
|
||||||
|
|
||||||
@@ -288,26 +288,26 @@ function declaredLuminance(el: HTMLElement): number | null {
|
|||||||
*
|
*
|
||||||
* The reader has asked for their palette on mail that brings its own, which
|
* The reader has asked for their palette on mail that brings its own, which
|
||||||
* cannot be done perfectly — this is the same bargain a dark-reader extension
|
* cannot be done perfectly — this is the same bargain a dark-reader extension
|
||||||
* makes. What it can do is tell the two kinds of colour apart: a **sheet** the
|
* makes. What it can do is tell the two kinds of color apart: a **sheet** the
|
||||||
* design sits on, which is what reads as a bright card and is neutralised, and
|
* design sits on, which is what reads as a bright card and is neutralized, and
|
||||||
* a **painted surface** — a button, a banner — which is kept whole so its
|
* a **painted surface** — a button, a banner — which is kept whole so its
|
||||||
* label stays legible on it.
|
* label stays legible on it.
|
||||||
*
|
*
|
||||||
* Two attributes come out of this. `data-ihm-keep` is a painted surface, which
|
* Two attributes come out of this. `data-ihm-keep` is a painted surface, which
|
||||||
* keeps its own colours. `data-ihm-in-keep` is an element sitting on one with
|
* keeps its own colors. `data-ihm-in-keep` is an element sitting on one with
|
||||||
* no background of its own, whose colour is left alone so a white label on a
|
* no background of its own, whose color is left alone so a white label on a
|
||||||
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralises
|
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralizes
|
||||||
* everything else.
|
* everything else.
|
||||||
*
|
*
|
||||||
* The distinction that matters is that being *inside* a painted surface is not
|
* The distinction that matters is that being *inside* a painted surface is not
|
||||||
* inherited past a sheet. A light table nested in a dark 600px card is still a
|
* inherited past a sheet. A light table nested in a dark 600px card is still a
|
||||||
* sheet and is still neutralised — that is issue #310, where a dark campaign
|
* sheet and is still neutralized — that is issue #310, where a dark campaign
|
||||||
* rendered with beige cards inside it because the exemption used to be
|
* rendered with beige cards inside it because the exemption used to be
|
||||||
* `[data-ihm-keep] *` in CSS and could not see the difference. Paint resumes
|
* `[data-ihm-keep] *` in CSS and could not see the difference. Paint resumes
|
||||||
* below it: a dark button inside that nested table is kept as usual.
|
* below it: a dark button inside that nested table is kept as usual.
|
||||||
*
|
*
|
||||||
* Nothing the sender wrote is removed, so turning the switch off puts the
|
* Nothing the sender wrote is removed, so turning the switch off puts the
|
||||||
* message back exactly as it was — and a colour that arrived from a `<style>`
|
* message back exactly as it was — and a color that arrived from a `<style>`
|
||||||
* block rather than an attribute is covered too, which is most of them in
|
* block rather than an attribute is covered too, which is most of them in
|
||||||
* modern templates.
|
* modern templates.
|
||||||
*/
|
*/
|
||||||
@@ -336,11 +336,11 @@ export function markKeptSurfaces(root: ParentNode): number {
|
|||||||
kept++;
|
kept++;
|
||||||
childrenOnPaint = true;
|
childrenOnPaint = true;
|
||||||
} else if (lum !== null) {
|
} else if (lum !== null) {
|
||||||
// A sheet, wherever it sits. Left unmarked so it neutralises, and it
|
// A sheet, wherever it sits. Left unmarked so it neutralizes, and it
|
||||||
// ends the protection rather than passing it on.
|
// ends the protection rather than passing it on.
|
||||||
childrenOnPaint = false;
|
childrenOnPaint = false;
|
||||||
} else if (onPaint) {
|
} else if (onPaint) {
|
||||||
// No background of its own, sitting on paint: leave its colour alone.
|
// No background of its own, sitting on paint: leave its color alone.
|
||||||
el.setAttribute("data-ihm-in-keep", "");
|
el.setAttribute("data-ihm-in-keep", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -5,9 +5,9 @@ import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages";
|
|||||||
* Translation, in about as little machinery as the job takes.
|
* Translation, in about as little machinery as the job takes.
|
||||||
*
|
*
|
||||||
* The English text is the key. `t("Archive")` looks "Archive" up in whatever
|
* The English text is the key. `t("Archive")` looks "Archive" up in whatever
|
||||||
* catalogue is loaded and returns the English if it is not there, which buys
|
* catalog is loaded and returns the English if it is not there, which buys
|
||||||
* three things worth more than tidy symbolic keys: there is no English
|
* three things worth more than tidy symbolic keys: there is no English
|
||||||
* catalogue to keep in step with the code, a missing translation degrades to
|
* catalog to keep in step with the code, a missing translation degrades to
|
||||||
* readable English rather than to `mail.list.archive`, and extracting a string
|
* readable English rather than to `mail.list.archive`, and extracting a string
|
||||||
* is wrapping it rather than inventing a name for it. Names are where
|
* is wrapping it rather than inventing a name for it. Names are where
|
||||||
* extraction stalls -- 55 components is a lot of small naming arguments.
|
* extraction stalls -- 55 components is a lot of small naming arguments.
|
||||||
@@ -71,7 +71,7 @@ export function t(source: string, vars?: Vars): string {
|
|||||||
*
|
*
|
||||||
* So a context can be given, and the lookup becomes context + source while the
|
* So a context can be given, and the lookup becomes context + source while the
|
||||||
* fallback stays the plain English. A translator sees the context and knows
|
* fallback stays the plain English. A translator sees the context and knows
|
||||||
* which sense to render; a catalogue that has not got round to it still
|
* which sense to render; a catalog that has not got round to it still
|
||||||
* renders the English word, which was right in English all along.
|
* renders the English word, which was right in English all along.
|
||||||
*
|
*
|
||||||
* The separator is a control character rather than a punctuation mark, which
|
* The separator is a control character rather than a punctuation mark, which
|
||||||
@@ -91,7 +91,7 @@ export function tc(context: string, source: string, vars?: Vars): string {
|
|||||||
* Two forms is an English assumption and does not survive the second phase of
|
* Two forms is an English assumption and does not survive the second phase of
|
||||||
* this: Russian and Ukrainian use three, and picking between them is not
|
* this: Russian and Ukrainian use three, and picking between them is not
|
||||||
* `n === 1`. `Intl.PluralRules` knows the rule for every language the browser
|
* `n === 1`. `Intl.PluralRules` knows the rule for every language the browser
|
||||||
* knows, so the catalogue supplies the forms and the runtime picks.
|
* knows, so the catalog supplies the forms and the runtime picks.
|
||||||
*
|
*
|
||||||
* The English `other` form is the key, so a call site reads as the sentence it
|
* The English `other` form is the key, so a call site reads as the sentence it
|
||||||
* produces and needs no invented name.
|
* produces and needs no invented name.
|
||||||
@@ -143,7 +143,7 @@ export function tNode(source: string, parts: Record<string, ReactNode>, vars?: V
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subscribe to catalogue changes without React. Used by the tests. */
|
/** Subscribe to catalog changes without React. Used by the tests. */
|
||||||
export function subscribeForTest(fn: () => void): () => void {
|
export function subscribeForTest(fn: () => void): () => void {
|
||||||
listeners.add(fn);
|
listeners.add(fn);
|
||||||
return () => void listeners.delete(fn);
|
return () => void listeners.delete(fn);
|
||||||
@@ -155,27 +155,27 @@ export function currentLanguage(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Put a catalogue in force.
|
* Put a catalog in force.
|
||||||
*
|
*
|
||||||
* Exported for tests and for the loader; nothing else should call it, because
|
* Exported for tests and for the loader; nothing else should call it, because
|
||||||
* the tag and the catalogue have to move together or `plural` selects with one
|
* the tag and the catalog have to move together or `plural` selects with one
|
||||||
* language's rules against another's forms.
|
* language's rules against another's forms.
|
||||||
*/
|
*/
|
||||||
export function setCatalog(tag: string, catalog: Catalog): void {
|
export function setCatalog(tag: string, catalog: Catalog): void {
|
||||||
/*
|
/*
|
||||||
* Publishing only when something actually changed is not an optimisation
|
* Publishing only when something actually changed is not an optimization
|
||||||
* here, it is the thing that stops an infinite loop.
|
* here, it is the thing that stops an infinite loop.
|
||||||
*
|
*
|
||||||
* The root keys its tree on the language version, so a publish remounts
|
* The root keys its tree on the language version, so a publish remounts
|
||||||
* everything. Remounting re-runs the effect that fetches the account's
|
* everything. Remounting re-runs the effect that fetches the account's
|
||||||
* settings file, which calls `hydrate`, which calls `applyLang`, which lands
|
* settings file, which calls `hydrate`, which calls `applyLang`, which lands
|
||||||
* back here -- with the identical tag and the identical catalogue. Publishing
|
* back here -- with the identical tag and the identical catalog. Publishing
|
||||||
* that non-change bumped the version again and went round for ever: the
|
* that non-change bumped the version again and went round for ever: the
|
||||||
* message list refetched on every pass, which is what it looked like from
|
* message list refetched on every pass, which is what it looked like from
|
||||||
* the outside.
|
* the outside.
|
||||||
*
|
*
|
||||||
* Reference equality is enough. `EMPTY` is a module constant and a
|
* Reference equality is enough. `EMPTY` is a module constant and a
|
||||||
* dynamically imported catalogue is cached, so the same language really does
|
* dynamically imported catalog is cached, so the same language really does
|
||||||
* hand back the same object.
|
* hand back the same object.
|
||||||
*/
|
*/
|
||||||
if (currentTag === tag && current === catalog) return;
|
if (currentTag === tag && current === catalog) return;
|
||||||
@@ -188,16 +188,16 @@ export function setCatalog(tag: string, catalog: Catalog): void {
|
|||||||
* Load and apply a language.
|
* Load and apply a language.
|
||||||
*
|
*
|
||||||
* English is the built-in: it is the source text, so there is nothing to fetch
|
* English is the built-in: it is the source text, so there is nothing to fetch
|
||||||
* and no chance of a missing catalogue leaving the app blank. Everything else
|
* and no chance of a missing catalog leaving the app blank. Everything else
|
||||||
* is a dynamic import, so a reader who never leaves English never downloads a
|
* is a dynamic import, so a reader who never leaves English never downloads a
|
||||||
* catalogue -- which matters, because the main bundle is already large enough
|
* catalog -- which matters, because the main bundle is already large enough
|
||||||
* to warn about.
|
* to warn about.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* The catalogue load that is in flight, so the first paint can wait for it.
|
* The catalog load that is in flight, so the first paint can wait for it.
|
||||||
*
|
*
|
||||||
* Without this, a cold load paints before the catalogue lands. Components
|
* Without this, a cold load paints before the catalog lands. Components
|
||||||
* recover -- the tree is rebuilt when the catalogue arrives -- but a string
|
* recover -- the tree is rebuilt when the catalog arrives -- but a string
|
||||||
* computed in an effect does not: a toast fired in that window is emitted in
|
* computed in an effect does not: a toast fired in that window is emitted in
|
||||||
* English and stays English, in an interface that is otherwise German.
|
* English and stays English, in an interface that is otherwise German.
|
||||||
* Reported as a stale-folder toast that ignored the language setting.
|
* Reported as a stale-folder toast that ignored the language setting.
|
||||||
@@ -224,7 +224,7 @@ async function loadLanguageNow(tag: string): Promise<void> {
|
|||||||
const mod = (await import(`../locales/${resolved}.ts`)) as { catalog: Catalog };
|
const mod = (await import(`../locales/${resolved}.ts`)) as { catalog: Catalog };
|
||||||
setCatalog(resolved, mod.catalog);
|
setCatalog(resolved, mod.catalog);
|
||||||
} catch {
|
} catch {
|
||||||
// A catalogue that will not load leaves English in force rather than a
|
// A catalog that will not load leaves English in force rather than a
|
||||||
// half-rendered page. `resolveUiLanguage` should already have prevented
|
// half-rendered page. `resolveUiLanguage` should already have prevented
|
||||||
// this; it being reachable at all is why it is caught.
|
// this; it being reachable at all is why it is caught.
|
||||||
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
|
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* is the reverse, which is why `uiLanguage` and `locale` are separate settings
|
* is the reverse, which is why `uiLanguage` and `locale` are separate settings
|
||||||
* rather than one.
|
* rather than one.
|
||||||
*
|
*
|
||||||
* Adding a language means adding its catalogue and then adding it here, in
|
* Adding a language means adding its catalog and then adding it here, in
|
||||||
* that order. RTL languages — Arabic, Hebrew, Persian — need bidi and layout
|
* that order. RTL languages — Arabic, Hebrew, Persian — need bidi and layout
|
||||||
* work well beyond strings, so they are not simply a matter of another entry.
|
* work well beyond strings, so they are not simply a matter of another entry.
|
||||||
*/
|
*/
|
||||||
@@ -26,8 +26,8 @@ export interface UiLanguage {
|
|||||||
/**
|
/**
|
||||||
* Machine-translated and not yet checked by somebody who speaks it.
|
* Machine-translated and not yet checked by somebody who speaks it.
|
||||||
*
|
*
|
||||||
* Stays true until a native speaker has actually read the catalogue and said
|
* Stays true until a native speaker has actually read the catalog and said
|
||||||
* so. It is not a measure of how complete the file is -- a catalogue can be
|
* so. It is not a measure of how complete the file is -- a catalog can be
|
||||||
* word-for-word finished and still read like a machine wrote it, which is
|
* word-for-word finished and still read like a machine wrote it, which is
|
||||||
* the thing this flag is about. Removing it is a deliberate act by a person,
|
* the thing this flag is about. Removing it is a deliberate act by a person,
|
||||||
* not something a coverage number earns.
|
* not something a coverage number earns.
|
||||||
@@ -56,8 +56,8 @@ export const DEFAULT_UI_LANGUAGE = "en";
|
|||||||
/**
|
/**
|
||||||
* The language to actually render in.
|
* The language to actually render in.
|
||||||
*
|
*
|
||||||
* A stored preference is only honoured if its strings are still shipped: a
|
* A stored preference is only honored if its strings are still shipped: a
|
||||||
* catalogue can be withdrawn, and an account carrying `de` from another
|
* catalog can be withdrawn, and an account carrying `de` from another
|
||||||
* machine must not leave this one claiming to be German while showing English.
|
* machine must not leave this one claiming to be German while showing English.
|
||||||
*/
|
*/
|
||||||
export function resolveUiLanguage(stored: string | undefined | null): string {
|
export function resolveUiLanguage(stored: string | undefined | null): string {
|
||||||
|
|||||||
+4
-4
@@ -109,7 +109,7 @@ export function parseLdif(text: string): LdifRecord[] {
|
|||||||
/**
|
/**
|
||||||
* An identity for an entry, derived from its distinguished name.
|
* An identity for an entry, derived from its distinguished name.
|
||||||
*
|
*
|
||||||
* Mozilla's schema has no UID, so a re-import had nothing to be recognised by
|
* Mozilla's schema has no UID, so a re-import had nothing to be recognized by
|
||||||
* and duplicated everything (#223). The `dn` is what the file actually carries,
|
* and duplicated everything (#223). The `dn` is what the file actually carries,
|
||||||
* and it does not need to be a durable identity to answer the only question
|
* and it does not need to be a durable identity to answer the only question
|
||||||
* being asked of it: have I imported this exact entry before? A migration is
|
* being asked of it: have I imported this exact entry before? A migration is
|
||||||
@@ -123,7 +123,7 @@ export function parseLdif(text: string): LdifRecord[] {
|
|||||||
* *same* address book, are one contact afterwards. Matching is per book, so
|
* *same* address book, are one contact afterwards. Matching is per book, so
|
||||||
* filing two directories in two books keeps them apart.
|
* filing two directories in two books keeps them apart.
|
||||||
*
|
*
|
||||||
* Normalised for case and for the spacing exporters differ in, which costs
|
* Normalized for case and for the spacing exporters differ in, which costs
|
||||||
* nothing when a file is compared against itself and helps when it is compared
|
* nothing when a file is compared against itself and helps when it is compared
|
||||||
* against a differently-produced export of the same directory.
|
* against a differently-produced export of the same directory.
|
||||||
*
|
*
|
||||||
@@ -131,10 +131,10 @@ export function parseLdif(text: string): LdifRecord[] {
|
|||||||
* and duplicates on re-import, as everything did before.
|
* and duplicates on re-import, as everything did before.
|
||||||
*/
|
*/
|
||||||
export function uidFromDn(dn: string): string | null {
|
export function uidFromDn(dn: string): string | null {
|
||||||
const normalised = dn
|
const normalized = dn
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.replace(/\s*([,=])\s*/g, "$1");
|
.replace(/\s*([,=])\s*/g, "$1");
|
||||||
return normalised ? `urn:x-ihasmail:ldif:${encodeURIComponent(normalised)}` : null;
|
return normalized ? `urn:x-ihasmail:ldif:${encodeURIComponent(normalized)}` : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ const ROLE_NAMES: Record<string, () => string> = {
|
|||||||
/** The folder's name as the reader should see it. */
|
/** The folder's name as the reader should see it. */
|
||||||
export function mailboxDisplayName(mailbox: { name: string; role?: string | null } | null | undefined): string {
|
export function mailboxDisplayName(mailbox: { name: string; role?: string | null } | null | undefined): string {
|
||||||
if (!mailbox) return "";
|
if (!mailbox) return "";
|
||||||
const localised = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
|
const localized = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
|
||||||
return localised ? localised() : mailbox.name;
|
return localized ? localized() : mailbox.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,7 +61,7 @@ export function mailboxDisplayName(mailbox: { name: string; role?: string | null
|
|||||||
* they were only looking at. Renaming a role folder is refused anyway, but
|
* they were only looking at. Renaming a role folder is refused anyway, but
|
||||||
* relying on that would be relying on a rule enforced somewhere else.
|
* relying on that would be relying on a rule enforced somewhere else.
|
||||||
*/
|
*/
|
||||||
export function isLocalisedName(mailbox: { role?: string | null } | null | undefined): boolean {
|
export function isLocalizedName(mailbox: { role?: string | null } | null | undefined): boolean {
|
||||||
return Boolean(mailbox?.role && mailbox.role in ROLE_NAMES);
|
return Boolean(mailbox?.role && mailbox.role in ROLE_NAMES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { marked } from "marked";
|
|||||||
* something is DOMPurify, which the app already carries for mail.
|
* something is DOMPurify, which the app already carries for mail.
|
||||||
*
|
*
|
||||||
* Rendered inline rather than in a shadow root the way mail bodies are: this
|
* Rendered inline rather than in a shadow root the way mail bodies are: this
|
||||||
* output is ours, sanitised and styled by `.md-body`, where an email arrives
|
* output is ours, sanitized and styled by `.md-body`, where an email arrives
|
||||||
* with a design of its own that has to be quarantined from the app's.
|
* with a design of its own that has to be quarantined from the app's.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function cardFromLdif(rec: LdifRecord): Partial<ContactCard> | null {
|
|||||||
/*
|
/*
|
||||||
* The four custom fields have nowhere of their own to go: JSContact has no
|
* The four custom fields have nowhere of their own to go: JSContact has no
|
||||||
* equivalent, and the schema does not say what they hold -- they are whatever
|
* equivalent, and the schema does not say what they hold -- they are whatever
|
||||||
* their owner decided. Appending them to the note keeps them, labelled the
|
* their owner decided. Appending them to the note keeps them, labeled the
|
||||||
* way Thunderbird labels them, which is worth more than the tidiness of
|
* way Thunderbird labels them, which is worth more than the tidiness of
|
||||||
* dropping something somebody chose to write down.
|
* dropping something somebody chose to write down.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* The theme used to be one enum — `system | light | dark | ihasmail` — where
|
* The theme used to be one enum — `system | light | dark | ihasmail` — where
|
||||||
* "ihasmail" carried a whole palette and implied dark. That works for exactly
|
* "ihasmail" carried a whole palette and implied dark. That works for exactly
|
||||||
* one palette. With several, the two questions come apart: **which palette**
|
* one palette. With several, the two questions come apart: **which palette**
|
||||||
* (the colours) and **which mode** (light or dark), and they are chosen
|
* (the colors) and **which mode** (light or dark), and they are chosen
|
||||||
* separately.
|
* separately.
|
||||||
*
|
*
|
||||||
* Every palette here is taken from the project that publishes it, all MIT, and
|
* Every palette here is taken from the project that publishes it, all MIT, and
|
||||||
@@ -32,7 +32,7 @@ export interface PaletteMeta {
|
|||||||
* the rest -- and are rendered translate="no" so a page translator leaves
|
* the rest -- and are rendered translate="no" so a page translator leaves
|
||||||
* them alone. "Classic" is not a name, it is an adjective describing the
|
* them alone. "Classic" is not a name, it is an adjective describing the
|
||||||
* theme, and a German reader should see "Klassisch". Reported by a native
|
* theme, and a German reader should see "Klassisch". Reported by a native
|
||||||
* speaker reviewing the German catalogue (#247).
|
* speaker reviewing the German catalog (#247).
|
||||||
*/
|
*/
|
||||||
translatable?: boolean;
|
translatable?: boolean;
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ export const PALETTES: PaletteMeta[] = [
|
|||||||
{ id: "ayu", name: "Ayu", credit: "Ayu by Konstantin Pschera (MIT)" },
|
{ id: "ayu", name: "Ayu", credit: "Ayu by Konstantin Pschera (MIT)" },
|
||||||
{ id: "kanagawa", name: "Kanagawa", credit: "Kanagawa by rebelot (MIT) — dark is Wave, light is Lotus" },
|
{ id: "kanagawa", name: "Kanagawa", credit: "Kanagawa by rebelot (MIT) — dark is Wave, light is Lotus" },
|
||||||
{ id: "everforest", name: "Everforest", credit: "Everforest by sainnhe (MIT)" },
|
{ id: "everforest", name: "Everforest", credit: "Everforest by sainnhe (MIT)" },
|
||||||
// Named for the design system rather than for GitHub: the colours are MIT,
|
// Named for the design system rather than for GitHub: the colors are MIT,
|
||||||
// the name and the logo are trademarks, and nothing here is endorsed.
|
// the name and the logo are trademarks, and nothing here is endorsed.
|
||||||
{ id: "primer", name: "Primer", credit: "GitHub's Primer primitives (MIT); not affiliated with or endorsed by GitHub" },
|
{ id: "primer", name: "Primer", credit: "GitHub's Primer primitives (MIT); not affiliated with or endorsed by GitHub" },
|
||||||
];
|
];
|
||||||
@@ -67,7 +67,7 @@ export function paletteMeta(id: PaletteId | string | null | undefined): PaletteM
|
|||||||
* against the OS. That was not true while `ihasmail` was dark-only: the mode
|
* against the OS. That was not true while `ihasmail` was dark-only: the mode
|
||||||
* then had to be overridden by the palette, and the toggle had to remember
|
* then had to be overridden by the palette, and the toggle had to remember
|
||||||
* which palette it had set aside on the way to light. Giving that palette a
|
* which palette it had set aside on the way to light. Giving that palette a
|
||||||
* light half removed the override, the memory and the greyed-out control in
|
* light half removed the override, the memory and the grayed-out control in
|
||||||
* one go.
|
* one go.
|
||||||
*/
|
*/
|
||||||
export function effectiveMode(mode: Mode, prefersDark: boolean): ResolvedMode {
|
export function effectiveMode(mode: Mode, prefersDark: boolean): ResolvedMode {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function splitLabel(label: string): { categoryKey: string; action: string
|
|||||||
|
|
||||||
const loaded = new Map<string, Promise<PermissionCatalog | null>>();
|
const loaded = new Map<string, Promise<PermissionCatalog | null>>();
|
||||||
|
|
||||||
/** The catalogue for a language, or null for English and for a language without a file. */
|
/** The catalog for a language, or null for English and for a language without a file. */
|
||||||
export function loadPermissionCatalog(tag: string = currentLanguage()): Promise<PermissionCatalog | null> {
|
export function loadPermissionCatalog(tag: string = currentLanguage()): Promise<PermissionCatalog | null> {
|
||||||
if (tag === DEFAULT_UI_LANGUAGE) return Promise.resolve(null);
|
if (tag === DEFAULT_UI_LANGUAGE) return Promise.resolve(null);
|
||||||
let pending = loaded.get(tag);
|
let pending = loaded.get(tag);
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { plural, t } from "@/lib/i18n";
|
|||||||
*
|
*
|
||||||
* This was a table of English strings carrying `label: "Monday"` and
|
* This was a table of English strings carrying `label: "Monday"` and
|
||||||
* `short: "M"`, rendered straight into the picker. The long names could have
|
* `short: "M"`, rendered straight into the picker. The long names could have
|
||||||
* become catalogue entries; the short ones could not, because "T" is both
|
* become catalog entries; the short ones could not, because "T" is both
|
||||||
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalogue
|
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalog
|
||||||
* cannot hold two translations under one key. Intl knows all of them.
|
* cannot hold two translations under one key. Intl knows all of them.
|
||||||
*/
|
*/
|
||||||
export const WEEKDAY_KEYS: Array<JSCalendarNDay["day"]> = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
export const WEEKDAY_KEYS: Array<JSCalendarNDay["day"]> = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
||||||
@@ -61,7 +61,7 @@ export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalenda
|
|||||||
*
|
*
|
||||||
* Built as whole sentences with placeholders rather than by concatenation.
|
* Built as whole sentences with placeholders rather than by concatenation.
|
||||||
* The old version appended fragments -- `base += " on " + names` -- which is
|
* The old version appended fragments -- `base += " on " + names` -- which is
|
||||||
* untranslatable however complete the catalogue is: German puts the weekday
|
* untranslatable however complete the catalog is: German puts the weekday
|
||||||
* list somewhere else in the clause, and a translator handed " on " alone
|
* list somewhere else in the clause, and a translator handed " on " alone
|
||||||
* cannot move it. Every branch below is one key a translator can rewrite in
|
* cannot move it. Every branch below is one key a translator can rewrite in
|
||||||
* full, including the word order.
|
* full, including the word order.
|
||||||
@@ -144,7 +144,7 @@ export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string
|
|||||||
* "first", "second", "last" -- words, not "1st".
|
* "first", "second", "last" -- words, not "1st".
|
||||||
*
|
*
|
||||||
* The suffix table this replaced ("st", "nd", "rd", "th") is English spelling
|
* The suffix table this replaced ("st", "nd", "rd", "th") is English spelling
|
||||||
* rules in code: German writes "1.", Japanese "第1", and no catalogue can
|
* rules in code: German writes "1.", Japanese "第1", and no catalog can
|
||||||
* reach a suffix chosen by arithmetic. JSCalendar's nthOfPeriod is 1-5 or -1
|
* reach a suffix chosen by arithmetic. JSCalendar's nthOfPeriod is 1-5 or -1
|
||||||
* in practice, so five words and "last" cover it; anything else falls back to
|
* in practice, so five words and "last" cover it; anything else falls back to
|
||||||
* the bare number, which is wrong in no language.
|
* the bare number, which is wrong in no language.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Settings that follow the account rather than the browser.
|
* Settings that follow the account rather than the browser.
|
||||||
*
|
*
|
||||||
* Everything used to live in localStorage, which meant no preference travelled
|
* Everything used to live in localStorage, which meant no preference traveled
|
||||||
* between devices — most painfully the default identity, where the fallback is
|
* between devices — most painfully the default identity, where the fallback is
|
||||||
* whichever address sorts first, so a forgotten setting sends mail from an
|
* whichever address sorts first, so a forgotten setting sends mail from an
|
||||||
* address the recipient may not know (issue #54).
|
* address the recipient may not know (issue #54).
|
||||||
@@ -152,7 +152,7 @@ export async function flushSettingsPush(): Promise<void> {
|
|||||||
if (!pending || !armed) return;
|
if (!pending || !armed) return;
|
||||||
const body = pending;
|
const body = pending;
|
||||||
pending = null;
|
pending = null;
|
||||||
// Serialise: two overlapping writes could land in either order.
|
// Serialize: two overlapping writes could land in either order.
|
||||||
inFlight = (inFlight ?? Promise.resolve()).then(() => writeSettings(body)).catch(() => undefined);
|
inFlight = (inFlight ?? Promise.resolve()).then(() => writeSettings(body)).catch(() => undefined);
|
||||||
await inFlight;
|
await inFlight;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export async function collectShare(): Promise<SharedContent | null> {
|
|||||||
* The bytes, rather than the Blob holding them.
|
* The bytes, rather than the Blob holding them.
|
||||||
*
|
*
|
||||||
* `new File([blob], …)` is correct and works in a browser, but a Blob
|
* `new File([blob], …)` is correct and works in a browser, but a Blob
|
||||||
* only counts as a part where the File constructor recognises it as one
|
* only counts as a part where the File constructor recognizes it as one
|
||||||
* -- and where it does not, it is stringified instead, producing a file
|
* -- and where it does not, it is stringified instead, producing a file
|
||||||
* containing the thirteen characters "[object Blob]" and no error
|
* containing the thirteen characters "[object Blob]" and no error
|
||||||
* anywhere. That is exactly what CI caught on Node 22 while it passed
|
* anywhere. That is exactly what CI caught on Node 22 while it passed
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function rulesToSieve(rules: SieveRule[]): string {
|
|||||||
*
|
*
|
||||||
* Saving replaces the whole script with a fresh serialization of the rules read
|
* Saving replaces the whole script with a fresh serialization of the rules read
|
||||||
* out of it, so whatever was not read is deleted. `sieveToRules` cannot raise
|
* out of it, so whatever was not read is deleted. `sieveToRules` cannot raise
|
||||||
* the alarm by itself: it skips what it does not recognise, so a script cut off
|
* the alarm by itself: it skips what it does not recognize, so a script cut off
|
||||||
* partway through parses cleanly into a shorter list and looks exactly like one
|
* partway through parses cleanly into a shorter list and looks exactly like one
|
||||||
* that genuinely has fewer rules. That is the shape of the loss in #76 -- a
|
* that genuinely has fewer rules. That is the shape of the loss in #76 -- a
|
||||||
* truncated download, a plausible parse, and a save that wrote the short
|
* truncated download, a plausible parse, and a save that wrote the short
|
||||||
@@ -326,10 +326,10 @@ export function reorderRules(rules: SieveRule[], fromId: string, toId: string, b
|
|||||||
*
|
*
|
||||||
* Rebuilt as whole sentences with placeholders. The old version concatenated
|
* Rebuilt as whole sentences with placeholders. The old version concatenated
|
||||||
* fragments -- a header name, an operator, a quoted value, joined by " and "
|
* fragments -- a header name, an operator, a quoted value, joined by " and "
|
||||||
* -- which no catalogue could fix: German puts the verb last, Japanese does
|
* -- which no catalog could fix: German puts the verb last, Japanese does
|
||||||
* not separate list items with a word at all, and a translator handed " and "
|
* not separate list items with a word at all, and a translator handed " and "
|
||||||
* on its own cannot move anything. Reported by a native speaker reviewing the
|
* on its own cannot move anything. Reported by a native speaker reviewing the
|
||||||
* German catalogue (#247).
|
* German catalog (#247).
|
||||||
*
|
*
|
||||||
* Intl.ListFormat does the joining, so "A, B and C" becomes "A, B und C" and,
|
* Intl.ListFormat does the joining, so "A, B and C" becomes "A, B und C" and,
|
||||||
* for an anyof rule, the disjunction the language actually uses.
|
* for an anyof rule, the disjunction the language actually uses.
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ describe("what the signature is allowed to mean", () => {
|
|||||||
expect(shouldRemember(report)).toBe(true);
|
expect(shouldRemember(report)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the same certificate again is recognised", async () => {
|
it("the same certificate again is recognized", async () => {
|
||||||
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
if (crypto.kind !== "intact") throw new Error("fixture should verify");
|
if (crypto.kind !== "intact") throw new Error("fixture should verify");
|
||||||
const known: KnownSigner = { fingerprint: crypto.cert.fingerprint, name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
|
const known: KnownSigner = { fingerprint: crypto.cert.fingerprint, name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
|
||||||
@@ -129,7 +129,7 @@ describe("matching a certificate to an address", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("canonicalisation", () => {
|
describe("canonicalization", () => {
|
||||||
it("turns a lone LF into CRLF and leaves an existing CRLF alone", () => {
|
it("turns a lone LF into CRLF and leaves an existing CRLF alone", () => {
|
||||||
const mixed = new TextEncoder().encode("a\nb\r\nc\n");
|
const mixed = new TextEncoder().encode("a\nb\r\nc\n");
|
||||||
expect(new TextDecoder().decode(toCanonicalCrlf(mixed))).toBe("a\r\nb\r\nc\r\n");
|
expect(new TextDecoder().decode(toCanonicalCrlf(mixed))).toBe("a\r\nb\r\nc\r\n");
|
||||||
@@ -141,7 +141,7 @@ describe("canonicalisation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The reason canonicalisation is applied at all: a store that hands back a
|
* The reason canonicalization is applied at all: a store that hands back a
|
||||||
* message with bare LFs would otherwise fail every signature it holds, and
|
* message with bare LFs would otherwise fail every signature it holds, and
|
||||||
* the message would look identical on screen while doing it.
|
* the message would look identical on screen while doing it.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* This works on bytes, not on a string, and that is the whole point. A
|
* This works on bytes, not on a string, and that is the whole point. A
|
||||||
* signature is over an octet sequence: decode it to text, re-encode it, or let
|
* signature is over an octet sequence: decode it to text, re-encode it, or let
|
||||||
* anything normalise a line ending on the way past, and the digest changes
|
* anything normalize a line ending on the way past, and the digest changes
|
||||||
* while the message still looks identical on screen. Every part here keeps a
|
* while the message still looks identical on screen. Every part here keeps a
|
||||||
* subarray of the original buffer rather than a rebuilt copy.
|
* subarray of the original buffer rather than a rebuilt copy.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
* without a certificate authority anywhere in the picture.
|
* without a certificate authority anywhere in the picture.
|
||||||
*
|
*
|
||||||
* So nothing here ever renders the bare word "verified". The caller is given
|
* So nothing here ever renders the bare word "verified". The caller is given
|
||||||
* the crypto result and the trust judgement separately, and has to say both.
|
* the crypto result and the trust judgment separately, and has to say both.
|
||||||
*/
|
*/
|
||||||
import { parseSignedData, type SignerInfo } from "./cms";
|
import { parseSignedData, type SignerInfo } from "./cms";
|
||||||
import { decodeTransfer, findPart, parseMime, toCanonicalCrlf, type MimePart } from "./mime";
|
import { decodeTransfer, findPart, parseMime, toCanonicalCrlf, type MimePart } from "./mime";
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
export interface SpamRule {
|
export interface SpamRule {
|
||||||
/** The rule's own name, as the filter wrote it. */
|
/** The rule's own name, as the filter wrote it. */
|
||||||
name: string;
|
name: string;
|
||||||
/** What it contributed. Negative moves the message towards clean. */
|
/** What it contributed. Negative moves the message toward clean. */
|
||||||
score: number;
|
score: number;
|
||||||
/** Rspamd's bracketed note, where there is one. */
|
/** Rspamd's bracketed note, where there is one. */
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* address book on it.
|
* address book on it.
|
||||||
*
|
*
|
||||||
* Reads are gated as well as writes. A machine that was trusted once still has
|
* Reads are gated as well as writes. A machine that was trusted once still has
|
||||||
* the residue, and honouring it would let a previous session's data surface in
|
* the residue, and honoring it would let a previous session's data surface in
|
||||||
* a later untrusted one.
|
* a later untrusted one.
|
||||||
*/
|
*/
|
||||||
const PREFIX = "ihasmail:";
|
const PREFIX = "ihasmail:";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* What the service worker cannot work out for itself.
|
* 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
|
* 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
|
* cannot read a catalog or a store. It is plain JavaScript copied into the
|
||||||
* build, outside the bundle, with no i18n and no idea which mailbox is 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.
|
* archive. Both of those are things a tab knows and can simply write down.
|
||||||
*
|
*
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user