A session signed in without "This is my own device" can no longer
administer. The server withholds the account's permissions from it and the
JMAP proxy refuses registry methods beyond the account's own, the same gate
ADMINISTRATION=0 uses. A borrowed or shared machine is where nobody should
be able to reset a password or remove a domain.
An administrator in such a session still sees Administration in the account
menu, greyed out, with the reason and the fix: sign in again with the box
ticked. The server tells that session only that the account administers.
The gate now reads the body only when it could name a registry method --
"x: in the text, or a \u escape that could spell one -- so ordinary mail
traffic from an untrusted session is forwarded untouched.
1 new string, translated in all nine catalogues, quoting each language's own
label for the tickbox; strings falling back to English stay at 16.
Administration's pages are tables, and the Settings-style second column
took width they need. The list of sections -- Directory > Accounts,
Mail > Domains -- now sits in the folder pane where Mail keeps its folders,
and the page is the open section alone, up to 1120px wide.
On a phone the list is in the drawer like every other section's, so a bare
/admin opens the first section rather than a page that is only a list. The
back link it needed is gone.
The Accounts list sent x:Account/query with {"type": "User"}, and a live
0.16 server refuses it: "unsupportedFilter - type". The registry keys a
filter by the property's name on the object, which for the discriminator is
@type, so the whole list failed to load. {"@type": "User"} is accepted.
The mock took the wrong name without complaint, which is how it shipped. It
now refuses any filter name the real server does not index for that object,
answering the way Stalwart does.
A role that can read domains now finds a Domains section beside Accounts:
list and search with each domain's account count and whether its DNS, DKIM
and certificate are managed automatically; add a domain; edit its
description, other names, catch-all address and plus addressing; copy its
DNS records one at a time or as a zone file; see its DKIM keys and their
stage; and remove it once no accounts use it.
The records come from the zone file Stalwart computes per domain. A long
DKIM record, which the BIND serialiser splits into quoted chunks, is joined
back into the single value a DNS provider's form wants.
Removing a domain takes its DKIM keys first, in the same request, because the
server will not remove a domain its keys still name. Removal is not offered
while accounts use the domain, or when the role cannot remove the keys.
The Administration nav is now built from the sections the role can read, and
the menu appears when there is at least one. The mock gains domains, DKIM
keys and zone files.
61 new strings, translated in all nine catalogues; strings falling back to
English stay at 16.
ADMINISTRATION=0 at launch removes in-app administration for everyone. The
account's permissions are no longer sent to the browser, so the menu never
appears, and the JMAP proxy refuses Stalwart registry methods other than the
account's own (settings, password, app passwords, API keys, public keys,
masked addresses). Hiding the menu alone would have left an administrator's
browser console able to make every call the menu made.
With administration on, the request body streams through untouched as before;
only an installation that turns it off reads and checks the body, forwarding
the parsed form so the server receives exactly what was inspected.
An account whose Stalwart role manages accounts now finds Administration in
the account menu. It lists, searches, creates and edits accounts -- display
name, other addresses, role, storage limit -- sets a new password, and
deletes, each offered only when the role holds the matching permission.
The server keeps the permissions list from GET /api/account, which it
already called for the edition and threw the rest away. Everything else is
JMAP x:Account, x:Domain and x:Role calls through the existing /api/jmap
proxy, so nothing new is stored and Stalwart decides every call.
Stalwart checks a grant against the caller's permissions but not a password
change or a delete, so an account that outranks the viewer is shown
read-only. Your own password is changed in Settings, which re-seals the
session; changing it here would strand it.
The mock server gains a directory behind the same permission names, with
MOCK_ROLE choosing admin, tenant-admin, helpdesk or user.
68 new strings, translated in all nine catalogues; strings falling back to
English stay at 16.
Two requests ignored the domain mapping from #238 and went to STALWART_URL:
- /api/account/* re-fetched the upstream session without upstreamFor(), so
once the five-minute session cache expired, password, app-password and
2FA calls for a mapped domain reached the default server.
- The locale lookup resolved Stalwart's apiUrl against the default server
rather than the one that issued the session.
Both now use the session's own server, with a test pinning the second.
The deploy script asked node for the build's version string, and that was
the only thing it needed node for. A host that runs everything as
containers has git and docker and nothing else, and today's deploy stopped
at 'node: command not found' before building anything.
The version is the same sum scripts/version.mjs does -- the commit's own
date plus the pull request it arrived through, or its short SHA -- done in
shell. Checked against the script on a merge commit, a plain commit and an
older one; all three agree.
A maximised composer goes position: fixed but stays a child of the dock,
and had no z-index of its own. The dock is a stacking context, so the
positioned parts of any composer later in the DOM (its recipients row, its
editor) painted straight over the full-screen one.
Give the maximised composer its own layer, and hide the other composers
while one is full screen: they cannot be reached anyway, and the 24px
inset would otherwise show their footers along the bottom edge. They stay
mounted, so nothing being written in them is lost.
Fixes#330
The check reported 41 stale keys per catalogue. Ten of them were.
The other 31 were strings held in constants and translated where they render --
t(b.description), t(group), t(c.label) -- so they reach t() as a variable and
there is no literal at the call site to find. The script already chased two of
those shapes, `label:` and objects named *_LABELS, with a comment about crying
wolf 33 times. The shapes kept coming: `description:` and `group:` on the
keyboard bindings, the calendar's view names, the read-receipt refusals, the
palette names.
Chasing them one at a time is the wrong shape of fix. Stale detection now asks
only "is this key still written down anywhere in the source" -- any string
literal counts. That under-reports, and that is the right way round: a missed
stale key costs a line of dead translation, a false one costs the credibility
of the check and every real finding after it. Which is what happened here --
these sat unread long enough to need a commit of their own.
Coverage keeps the strict set. The two questions need different nets, and
widening the one that measures what a catalogue *owes* would count every CSS
class and JMAP method name as an untranslated string -- it read 29% while I had
them sharing a set. `wanted` is the obligation, `seen` is the evidence.
What was actually dead, removed from all nine: "Availability on {date}",
"Import vCard", "PDF", two settings hints replaced by rewordings that are still
live, the Catppuccin palette description, and Tuesday through Friday -- left
behind when the week-start dropdown narrowed to the three days a week actually
starts on, and appearing since only in comments.
Coverage is unchanged at 1269/1285: none of the ten was ever owed.
TypeScript 7 is the native port: the package ships a `tsc` shim over a Go
binary, and `typescript` now exports `version` and `versionMajorMinor` and no
compiler API. Every `ts.createSourceFile` in scripts/ has been throwing
"Cannot read properties of undefined (reading 'Latest')" since the 5.9.3 → 7.0.2
bump -- four of the five i18n scripts dead, only i18n-extract still running.
Nothing noticed because no workflow runs them. The catalogue gate for nine
languages has been dark, and the only signal was running it by hand.
There is no official TS7 API package (@typescript/ast and @typescript/api are
both 404), and the alternative was rewriting 493 lines and 25 distinct AST
calls, including the JSX guards, against a different tree -- in tooling with no
tests of its own. So `typescript-ast` is an npm alias for the last TypeScript
carrying the JS API. It parses; `typescript` still type-checks and builds. Two
entries, two jobs, said so in each script so the next reader does not delete
one as a leftover.
What the gate says now it can speak: catalogues are green and coverage is 100%.
The "16 falling back to English" it reports in every locale are placeholders,
example domains, a product name, a licence id and the quote glyph -- strings
that should stay English. The 41 stale keys per locale are real dead weight and
are left for their own change.
The setting reached only as far as the query. It set `collapseThreads`, so the
list correctly showed individual messages -- and then everything downstream
carried on working in threads. Opening one message highlighted every row of its
thread and filled the reading pane with the whole conversation, which is the
grouping the setting was turned off to avoid. The empty pane went on offering
"62 conversations" either way.
Three places had to learn about it, and the two rules behind them now live
together in lib/openMessage.ts:
- the row highlight matched on threadId, so siblings lit up
- ThreadView rendered every message the thread held
- the empty state named conversations regardless
The thread id stays in the path and loading is unchanged; the opened message
rides in `m`. Keeping it in the URL rather than in memory is what makes a
reload or a shared link come back to the same message, and an id that names
nothing in the thread falls back to the conversation -- which is what a link
from somebody with the setting on looks like, and what a stale parameter looks
like after switching back. Better a conversation than an empty pane.
Nine catalogues gain "No message selected" and "Select a message to read it
here"; "{n} messages" was already there, plural forms and all.
`htmlBody` is a derived list, not a filter: RFC 8621 §4.1.4 gives a message
with no HTML alternative one anyway, holding the text/plain part. Testing
`Boolean(htmlRaw)` therefore answered "this is HTML" for every plain-text
mail, sending it to HtmlBody and `.ihm-email-root`, which is
`white-space: normal` and collapses every line break. Hard-wrapped mail
arrived as a single paragraph with the signature and the quoted reply run
into the prose.
Confirmed live against Stalwart 0.16.21 (2026-09-10): a plain-text message
comes back with `htmlBody` and `textBody` naming the same part, typed
text/plain, while a real multipart/alternative names two different parts.
`type` was already in BODY_PROPS; nothing looked at it.
TextBody was written for exactly these messages and was simply unreachable,
so this also restores what it does -- pre-wrap, quote-depth colouring and the
collapsible quoted block, none of which had ever fired on plain-text mail.
The account behind it was renamed from LINUXexpert-org to jcoffey-dev,
and GitHub does not redirect the old name: github.com/sponsors/
LINUXexpert-org answers 404 while the new one answers 200. So the
Sponsor button on this repository has been leading nowhere.
Worth fixing rather than leaving to redirect, because a released
username can be registered by anyone -- a stale link stops being a dead
end and starts being someone else's page.
Three pins and a types package all described Node 22, and moving any one
of them alone puts the build somewhere the others are not: @types/node
on its own would typecheck against APIs the runtime does not have, and
the base image on its own would ship a major CI never exercised. So
ci.yml, publish.yml, release.yml, both Dockerfile stages and
@types/node move in one change.
Worth knowing before this is deployed: 26 is Current, not LTS. node:26-
alpine reports lts=none, where 24-alpine is Krypton and the 22-alpine we
are leaving is Jod. 26 is due to become Active LTS in October. Nothing
here needs 26 over 24 -- the pins are a single number if the LTS line is
preferred.
engines stays at >=20.19, which is the floor for running ihasmail rather
than the version we build it on; the README's recommendation follows CI
to 26.
Checked on the runtime, not just in CI: the image builds on 26-alpine,
starts, and answers /api/health, and the login, SSE and body-carrying
POST checks from the node-server upgrade pass against a server on
26.8.1.
All three entry points we import survive the major unchanged: `serve`
keeps its `(options, listeningListener)` signature and still accepts
`fetch`, `hostname` and `port`; `RESPONSE_ALREADY_SENT` is still exported
from `utils/response`; `getConnInfo` is still on `conninfo`. The peer is
hono ^4 and the engine >=20, both of which we already meet.
What v2 adds is two defaults worth knowing about. `overrideGlobalObjects`
swaps in a lighter Request/Response, and `autoCleanupIncoming` destroys
an incoming request the app never finished reading -- which is the
behaviour you want behind a proxy, and is on by default.
Neither is something the unit tests would notice, so this was run rather
than reasoned about. Against the mock: login, an /api/events stream, and
a POST carrying a body through to upstream. The SSE path is the one that
matters, since it writes to the raw ServerResponse and hands back
RESPONSE_ALREADY_SENT; it answers with the same headers, the same
chunked encoding and the same bytes as 1.19.17 does on the same script.
@vitejs/plugin-react 6 peers on vite ^8 and nothing lower, so the build
had to move before the plugin could. vite 8 bundles with rolldown rather
than rollup, which is most of what is here.
The object form of `manualChunks` -- a chunk name against the list of
packages in it -- is gone; rolldown takes groups tested against module
paths instead. Same two chunks come out, `vendor` and `icons`, with the
same contents; `icons` is tried first because the first matching group
wins. `rollupOptions` is now a deprecated alias, so it is spelled
`rolldownOptions`.
The lockfile is regenerated rather than patched. vitest depends on vite
itself, and an incremental install was happy to leave 6.4.3 hoisted for
vitest while web built against 8.3.0 -- two majors in one tree, which is
not a state to ship. A clean install collapses to one.
vite 8 wants Node ^20.19 || >=22.12, above the >=20.10 the README and
engines promised, so both say 20.19 now. CI and the image are on 22 and
were never affected.
Rolldown reports two modules that are imported both statically and
dynamically, so the dynamic import cannot split them out. That is true
of the source either way -- store/sieve.ts has three static importers
and one dynamic -- and is left alone here.
TypeScript 7 removes `baseUrl` outright and refuses a non-relative entry
in `paths`, so the typecheck stops on tsconfig.json before it reaches a
line of our code. A leading `./` says the same thing without it: paths
resolve against the tsconfig's own directory, which is what `baseUrl:
"."` was there to arrange.
Nothing here waits for the upgrade. Relative paths without a baseUrl
have been the supported spelling since 4.4, so this typechecks the same
under 5.9.3 today as it will under 7. Vite resolves `@` from its own
alias in vite.config.ts and never read this.
With this in, 7.0.2 typechecks both workspaces clean -- the two
tsconfig errors were all that stood in the way, not the first two of
many.
GHSA-82fw-gwwq-j7x9 -- arbitrary file read through @vitest/mocker's
redirect mock -- has no fix in the 3.x line. The patched versions are
4.1.11 and 5.0.0-rc.2, so clearing it means the major. vite stays at
6.4.3: vitest 4 accepts ^6, and nothing outside devDependencies moves.
The bump surfaced a bug of ours rather than one of vitest's. vi.spyOn
now hands back the spy already installed on a method instead of wrapping
it in a fresh one, so a spy installed in beforeEach keeps its call count
across tests. compose-from-share expected two uploads and saw three: its
own two, plus the one from the test before it. The assertion was only
ever passing because each test happened to get a new spy.
Both suites now restore between tests, which is what the other five
spying suites already do. webpush had the same leak with no assertion
close enough to catch it.
Three medium advisories land on hono before 4.13.5: a toSSG() path
escape, a query parser that reads parameters past the URL fragment, and
unbounded dot-notation nesting in parseBody(). Only the second one
touches this server -- c.req.query() is read in imageproxy, icsproxy and
app -- and even there safeFetch validates the value it actually fetches
rather than a separate pre-check, so there was nothing to desync. toSSG
and parseBody are never called. The bump is still worth taking on its
own: it is a patch release with no API change.
The declared range moves with it, from ^4.7.4 to ^4.13.7, so the
security floor is recorded in server/package.json and not only in the
lockfile.
The dependabot.yml is the actual fix for how these were found. There was
no config, so nothing opened a PR and the alerts sat on a dashboard
until someone thought to look. Routine updates now group into one PR a
week; majors stay separate, because they are migrations.
Closes#310.
A dark campaign rendered with beige cards inside it. markKeptSurfaces
marks any element whose declared background is below the luminance
threshold, with no area cap, so a 600px layout card is marked exactly
like a button. The CSS then exempted the marked element and its whole
subtree via [data-ihm-keep] *, so a light table nested in that card was
never touched. In the reported specimen 14 of 21 light panels survived.
The rule now is that being inside a painted surface is not inherited past
a sheet. The walk tracks that state and emits a second mark,
data-ihm-in-keep, for elements sitting on paint with no background of
their own; the CSS exempts those explicitly instead of exempting every
descendant. A nested light sheet ends the protection, and paint resumes
below it, so a button inside such a sheet is still kept whole.
The alternatives in the report were not taken. Dropping the descendant
half of the selector outright puts back what #294 fixed: a nested label
on a coloured cell loses its colour. An area threshold is a magic number
that misfires on both a legitimate hero banner and a small dark panel
with a light chip in it.
The tests assert against the neutraliser selector lifted out of
EMAIL_BASE_CSS rather than against the marks. The first draft of them
checked which attributes were set and passed against the unfixed code,
which proved nothing: the bug was in the rule that reads the marks, not
in the marking. All four fail without this change.
The deploy on 2026-09-08 went out at the origin and did not arrive.
Cloudflare went on handing out the previous `sw.js` -- `cf-cache-status:
HIT`, with an edge TTL of four hours, longer than the hour we asked for
-- because the file is neither a hashed asset nor HTML and so fell into
the ordinary `max-age=3600` case.
That is not a freshness preference. The service worker is the app's whole
update mechanism: a browser holding the old one goes on being served the
shell that worker knows and never learns a deploy happened, so the deploy
simply does not land. The manifest matters for a second reason -- the two
have to agree. A fresh manifest advertising a share target, answered by a
worker that has never heard of one, sends the share to the server for a
405. Either being old is survivable; disagreeing is not.
`no-cache` rather than `no-store`: both may still keep a copy, they just
have to revalidate it, which is a 304 and costs nothing. Neither gets to
answer with its own copy without asking.
Narrow on purpose -- two files, named, rather than a policy that quietly
stops the icons and fonts being cached as well.
Both happen in the background. The phone stays where it is.
This was twice described as impossible, here and in FEATURES.md: the
service worker was said to have no session, so anything touching mail had
to open the app. That is wrong, and checking it rather than repeating it
is the whole of this change. ihasmail's session is an httpOnly cookie
against its own origin and the only other thing the API asks for is a
fixed `x-requested-with` header, which is not a secret and is not held
anywhere. A same-origin fetch from the worker carries the cookie like any
other. Confirmed against the mock: logging in with curl and then issuing
`Email/set` with nothing but that cookie and the static headers marked a
message read and moved it to Archive, HTTP 200. Nothing the tab holds in
memory is involved, because the API asks for none of it.
Two actions, because `maxActions` is two on Android and anything past it
is dropped without a word. Archive and Mark as read are the two worth
having: they are what somebody does to a notification they have already
read the whole of. Reply is not among them -- it would have to open the
app, which is what tapping the notification does already.
The worker still cannot reach a catalogue. It is plain JavaScript copied
into the build, outside the bundle, with no i18n and no idea which
mailbox is the archive. So the app writes both down in the same cache it
already uses for handoffs, and rewrites them whenever the language, the
account or the folder list changes. Where there is no such note -- between
installing this worker and next opening ihasmail -- the notification
appears with no buttons at all, rather than English ones over a mailbox
guessed by name. That also fixes two strings the worker had always shown
in English regardless: "New mail" and "(no subject)".
A session can be gone by the time a button is pressed. That comes back as
a refusal and the notification says so, rather than vanishing as though
it had worked. It does not open the app to recover: being interrupted is
what the button existed to avoid.
The two claims that were wrong are corrected rather than quietly deleted,
including the one about push renewal -- which still needs a tab, but for
a different reason than the one given. The reason is when the worker
runs, not what it may do: it wakes only for a push, and the push stops
when the subscription lapses.
Two new strings, in all nine catalogues.