A folder could only be moved by dragging it, which is slow in a long list
and not offered at all on a touch screen. Its menu now has "Move to…",
which opens the searchable folder picker that moving messages already
uses, with a "Top level" row above the folders.
The picker lists only legal destinations: the same rules as a drop -- not
into itself, its own subtree or the parent it already has -- plus the
rights a picker has to check up front because it shows every folder at
once: mayRename on the folder being moved, which RFC 8621 uses for
reparenting, and mayCreateChild on the destination. Both only say no on
shared mail. The move itself goes through the same path as a drop, so the
toast and the expanded destination are unchanged.
"Move “{name}” to…" and "Top level" are in all nine catalogues.
Closes#355
i18n-literals now flags a template literal in a UI attribute or prop when
there are words between its values: `Remove ${email}`, `${name} — shared by
${owner}`. It cannot be a catalogue key as written, so neither the key
exemption nor the sentence-shape test applies. A template that is only
punctuation around values, like `${name} (${size})`, is left alone.
The twelve it found are now keys with placeholders: the quota bar title,
the address menu's label, a folder's subfolder unread count, a recipient
chip's remove button, the contact editor's title, shared and available
calendars and address books, the date and time fields' labels, the
attachment title's fallback name, and the free/busy bar. The bar showed
the raw JMAP busyStatus ("confirmed") and now says Busy, Tentative or
Unavailable.
11 new keys in all nine catalogues. Remove {address}, Date, Time, Busy and
Tentative already existed.
i18n-literals checked title, aria-label, placeholder and alt on elements,
but not the same English passed to a component, so a MenuItem label or a
Popover ariaLabel written as a literal went through. It also excused a
literal that happened to be a catalogue key. That exemption is meant for
English held in a constant and translated where it renders, and a literal
written straight into a JSX attribute has no such render site. No
component passes its props through t(). And neither half of i18n:check
was run with --check, so a finding printed and the script still exited 0.
Component props are checked now, a key no longer excuses a literal in an
attribute, and both scripts run with --check. That found 28 strings
rendering English in every language: 19 already had keys and are wrapped,
and 9 are new keys in all nine catalogues. The contact editor's Save and
Saving… buttons are wrapped as well, on the same line.
The sidebar's right edge is now a splitter, like the one between the
message list and the reading pane: drag it between 240 and 480px, move it
with the arrow keys, double-click to put it back. The width is a device
setting, and stays null until someone drags, so a width set in the
reader's own CSS through --sidebar-w is kept until they choose otherwise.
Hidden on a phone, where the sidebar is a drawer, and while collapsed.
Arrow keys on either splitter moved the pane and never saved it: the
keyboard path called onResize without onEnd. It ends each key press now,
and both views keep the in-progress size in a ref as well as state, so
the end reads the value set in the same tick.
The message-list splitter's accessible name was an untranslated literal.
It goes through translate() now, and it and the sidebar's new name are in
all nine catalogues.
Closes#345
The 09:00 UTC schedule competes for GitHub's busiest slot. The first
scheduled run started almost six hours late, and on 2026-09-14 no run had
started four and a half hours in, so that week was cut by hand. 09:17 is
still best-effort, but no longer queues behind every on-the-hour schedule.
ihasmail-oneshot for a new single-host deployment, stalwart-migrator for
an existing 0.15.5 server, each with what it is for and where it starts.
Quick start also says, before its steps, that a host with no Stalwart yet
can use ihasmail-oneshot instead.
Both said the live instance runs 0.16.21. KNOWN-ISSUES now records that
0.16.22 was tested and lists what it changed for a client, and notes the one
entry it touches: an event read by its stored id reports a null baseEventId,
while a one-off from an expanded query still carries a base.
The shareWith entry still described calendars and address books leaving the
field out, which 0.16.21 fixed; it now says so, and that Mailbox/get alone
still omits it.
0.16.22 changed four things a client sees from CalendarEvent/get and
ContactCard/get. Read from its source and the tests that came with it:
- baseEventId is the master's id on a synthetic id and null otherwise; an
event read by its stored id used to report its own id. A one-off from an
expanded query still has a synthetic id, so it still carries a base.
- recurrenceRule and recurrenceOverrides named on a synthetic id come back
null rather than absent.
- useDefaultAlerts is the reader's own and reads false until set.
- an empty properties list returns id alone, for both methods. pick already
did that, so only a comment changes for contacts.
With properties omitted the stored object comes back as before.
The README said a one-off now carries a null base, which only holds for one
read by its stored id; it now says that, and that the mock follows.
The live instance was tested on 0.16.22, not only read against its diff. The
badge says tested against it again, and 0.16.21 becomes the release before.
The live instance moved to Stalwart 0.16.22 on 2026-09-13. The badge and the
requirements section now say so. The hand validation stays credited to 0.16.21,
because 0.16.22 was read against its diff rather than re-run, and the four
calendar and contacts JMAP changes it makes are listed, along with the fact
that the mock does not follow them yet.
Stalwart explains a refused change in English, and several of its words
reached the page as they were: "Invalid domain name" for a reserved TLD,
"Invalid email address" for a catch-all, a grant refusal, and ihasmail's own
proxy messages. Every registry error type now has its own message, and a
value one of the registry's string validators refused is recognised by the
validator's wording and explained again. A domain clash or a missing domain
is worded for a domain rather than an account.
The one exception is kept on purpose: a password policy's reason follows a
translated sentence, because the rule is the server's and dropping it would
leave no way to find out why.
The mock now refuses a reserved TLD and a catch-all without a domain the way
the live server did. KNOWN-ISSUES records the fix, and that the last two live
cases -- an administrator-set password and the outranking guard -- held.
15 new strings in all nine catalogues, 3 retired; strings falling back to
English stay at 16.
KNOWN-ISSUES carried Administration as read from source and untested. It
has now run against production: the Accounts filter was wrong (type, not
@type; fixed in #336) and everything else held -- permission casing, Basic
auth on admin calls, account and domain shapes, the zone file format, DKIM
lookup by domain, catch-all addresses, and removing a domain with its keys.
What remains unproved (an administrator-set password, and the grant-check
gap behind the outranking guard) is said plainly, along with the untranslated
invalidPatch description and the two gates that decide who may administer.
README gains Administration in its feature list and MOCK_ROLE among the mock's
switches.
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.