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.
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.
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.
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.
@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.
The bullet asserted that ihasmail stays stateless without saying what
that is scoped to, which reads as a claim about the file rather than
about the process. Name both halves: the format is ihasmail's, the file
is the account's.
The README badge still read 0.16.20, in both the label and the shield it
links to. It is the first version number a reader sees and it was the one
place the prose update missed, because it is HTML rather than Markdown.
Two entries had gone further than stale and were wrong. FEATURES said
occurrence ids are not stable across a write, and KNOWN-ISSUES carried
that as a live hazard with the five-week series that proved it. 0.16.21
fixed exactly that: an occurrence is identified by its recurrence id now,
and holding an id across a write keeps it on its own date. Both entries
say so, keep the old behaviour and the evidence for it because the client
still supports 0.16 as a whole, and record what replaced it.
The defence in the client stays either way, and the reason is written
down: re-resolving by recurrenceId costs one lookup, a date can still
leave a series, and 0.16.20 is still a server someone may be running.
The KNOWN-ISSUES header now says the live instance runs 0.16.21 and,
unlike the upgrades before it, that this one was re-run rather than read
against the diff — with what was exercised by hand.
Themes were not in "What's in it" at all, which is odd for something a
reader sees before anything else. There is now a bullet for the twelve,
saying that palette and light-or-dark are separate choices and that a
palette which would not meet the contrast this app claims is not written.
FEATURES lists the six new palettes and what each borrows for its light
half, and records the rule that changed with them: body text used to be
checked and then accepted or rejected, which would have turned away five
of the six over a bar their designers never aimed at, so it is now lifted
along its own hue like every other text tone. Twenty-one of the twenty-two
borrowed halves need at least one lift.
The message-theming entry gained the second switch, including why the
first one alone did nothing for most real mail.
The Stalwart section records what the release is validated against rather
than only what it requires: 0.16.21, run against a real instance, with the
four client-visible JMAP changes named. The mock section gains its third
switch and says it tracks the current release, confirms each behaviour
against a real server first, and rewrites rather than deletes the test
that pinned an old behaviour.
A signed message now says whether that holds up, as it is read. This is
verification only: nothing here signs, encrypts or decrypts, and the
private-key question that blocks those is untouched. Verifying needed
none of it, because the certificate travels inside the message -- which
is why this is the half that could be built.
What it checks. For multipart/signed carrying PKCS#7, the exact bytes of
the signed part -- headers included, canonicalised to CRLF -- are hashed
against the messageDigest attribute, and the signature over the signed
attributes is verified with WebCrypto against the certificate inside the
message. RSA PKCS#1 v1.5 and ECDSA over P-256/384/521, with SHA-256, 384
or 512.
The trust model is the design, and it is deliberately small. A browser
has no system trust store, and the certificate arrives inside the
message, so anyone can self-sign as anyone: on its own a good signature
shows only that the sender held the key they attached. So the word
"verified" is never rendered, and the reassuring case is not the loud
one. What carries the weight is remembering -- the first signed message
from an address pins its fingerprint, later ones are compared, and a
signer that changed is reported with both names and told to check by
another route. Trust on first use, no certificate authority anywhere.
The pins live in the account's settings rather than the browser: one
that only a single device knew would greet the same correspondent as new
everywhere else, which is how people are trained to click past the one
warning that matters. A pin records the message that created it, so the
message that established a signer keeps saying so instead of appearing
to be corroborated by itself -- without that, the very first signed
message anybody receives reads as "the same signer as before", where
before is itself. A changed, mismatched or expired signer is never
pinned, since writing the anomaly into the baseline makes every later
message agree with it.
Three things are declined rather than attempted, and all three say
"could not check" rather than "does not check out", because ignorance
and an accusation are different claims:
- OpenPGP, by name. The signature carries no key and there is nowhere
to get the sender's: x:PublicKey is the account's OWN registry, and
a keyserver or WKD lookup would tell a third party who you
correspond with -- the leak the image proxy exists to close.
- SHA-1. Not forgeable in practice today, still not something to put a
tick beside.
- RSA-PSS, whose salt length lives in parameters this does not read.
Guessing wrong would report a good signature as bad.
Nothing validates a chain: no CA bundle is shipped and revocation is not
checked. "Issued by" reports what the certificate claims, and a
self-signed one claims itself.
The DER, CMS, X.509 and MIME readers are hand-written and deliberately
narrow -- no new dependency, and the whole verifier is a lazily imported
8.6 kB chunk that a reader of unsigned mail never downloads. The one
place this is easy to get quietly wrong has its own function and its own
test: signed attributes are signed as a SET OF, not as the [0] IMPLICIT
they arrive as, and hashing the message instead would make every
signature "pass".
Tested against real `openssl smime -sign` output rather than hand-built
fixtures -- RSA, ECDSA, a tampered copy, and a valid signature by a
certificate for somebody else -- because a signed message written by
hand only agrees with whatever its author believed the format to be.
Also driven in a browser against the mock, which now serves three real
signed messages so every branch of the banner is reachable.
Translations: 34 new strings in all nine catalogues, 306 entries.
Falling back to English is unchanged at 24 per language.
A fix announced as "live" on a closed issue means the QA webmail server,
which deploys from main. It does not mean the image anybody has pulled:
that is cut weekly, on Mondays at 09:00 UTC, so between one Monday and
the next main is ahead of the newest release by up to a week.
This confused the reporter on #174 this week, and it was my wording that
did it -- three comments invited him to try changes that were merged and
not yet published. The distinction was written down nowhere.
Placed above "this file is for people working on ihasmail" rather than
under Container images, because the person who needs it is reading to
decide whether to pull, and by the time they reach that section they have
usually pulled. Container images gains the cadence too, since "on every
release" says nothing about how often a release happens.
The hour is given as approximate on purpose: GitHub runs scheduled
workflows best-effort and delays them when its queue is busy.
Docs only. No strings added, no catalogues touched.
README has said `docker run ... ghcr.io/coffey-labs/ihasmail:latest` since the
Docker instructions were written, and the docs site repeats it in four places.
Nothing ever pushed that image. `docker pull` answers `denied`, because the
package does not exist: .github/workflows held ci.yml and nothing else, and
there is no reference to ghcr.io, docker/build-push or docker push anywhere in
this repo. The instructions have been wrong the whole time.
Adds the workflow that makes them true. It fires on a published release, and
by hand for a ref -- the same dispatch trigger ci.yml carries, and the only
way to build an image for the tags that predate this file.
Two architectures on native runners rather than one build under QEMU.
Emulated arm64 runs `npm ci` and the Vite build through instruction
translation, which takes tens of minutes and sometimes exhausts memory;
ubuntu-24.04-arm is free for public repositories and does it at native speed.
The cost is pushing by digest and joining the two into one manifest at the
end, which is what the third job does.
`latest` moves only for a real release. A prerelease that moved it would hand
every `:latest` deployment an unfinished build, and a dispatch run has to ask
for it deliberately.
Also documents the images in README: which tags exist, that the dated tag is
the one to pin, and that building it yourself is still fully supported --
`docker compose up --build` is unchanged and the image is a convenience, not a
new requirement.
Worth knowing before the first run: GHCR creates a new package **private**,
even for a public repository, so an anonymous pull will still be refused until
the visibility is changed by hand. That is written at the top of the workflow,
because it is the failure that looks like success.
One ihasmail in front of several Stalwarts, from #238. STALWART_URL stays
required and stays the default, so an installation that sets nothing behaves
exactly as it always has -- the mapping only adds domains that go elsewhere.
An unlisted domain goes to the default. So does a bare username, which
Stalwart accepts and which has no domain to map at all.
A listed domain never falls back. If its server is unreachable that sign-in
fails rather than retrying against the default, because falling back would
authenticate somebody against a server their domain was deliberately routed
away from -- and if the same account name existed there, they would land in
another tenant's mailbox. The fallback is a decision about unmapped domains,
taken before any network call, not a recovery path.
Smaller than it sounds because only four places read config.stalwartUrl, all
in upstream.ts. The upstream session now records which server issued it, since
the relative URLs inside it only mean anything against that server, and every
route already holding a session gets the right upstream without a second
lookup. The client is untouched: it talks to one proxy and never learns there
is more than one server behind it, which is exactly why this is small and
several-servers-at-once is not.
The upstream is derived from the username rather than stored on the session,
so a mapping change takes effect on restart instead of being frozen into
sessions that outlive it.
Validated at boot the way the settings policy is: malformed JSON, a duplicate
domain once normalised, a missing file or a value that is not an http(s) URL
all stop the server. Domains are lower-cased and stripped of a trailing dot,
because that is how one arrives off a username and comparing them any other
way means a mapping that silently never matches. The servers themselves are
not contacted -- a mapping is a routing table, not a health check, and one
customer's outage must not stop ihasmail starting for the other four.
Eight tests on the routing, two on the shipped example, and the four refusals
checked by hand against a real config load.
#231 added the policy but nothing to copy. The repo already answers this the
same way four times over -- Caddyfile.example, deploy.example.sh,
nginx.example.conf, .env.example -- and the new feature was the one thing
configurable here with no example beside it.
settings-policy.example.json carries all three sections with the reasoning in
it, including the part worth being deliberate about: a `changes` entry
overrides a decision a reader has already made, and if you want it to stay put
regardless that is `enforced` instead. JSON has no comments, so the commentary
is in `_`-prefixed keys, which is safe because the server reads three names and
ignores everything else.
A test asserts the shipped example stays valid against the rules the parser
enforces -- unique versions, settings objects, no comment key colliding with a
real section. An example that has drifted is worse than none: somebody copies
it, the server refuses to start, and the first experience of the feature is a
crash loop.
.env.example gains the four variables, commented out, with the file form and
the inline form and the note that the file wins over the variables.
Confirmed against the real image on the deploy host rather than reasoned
about: an immutable container -- --read-only, IMMUTABLE=1, SESSION_FILE=
empty -- starts and serves the policy both with a read-only file mount and
with the environment variables alone. The feature costs nothing in
immutability, because the only thing it writes is the applied-changes stamp,
and that goes in the reader's own settings file on Stalwart like every other
setting.
The last third of #207, and the only part that remembers anything.
An admin turns a setting on for people who are already here -- which a default
cannot do, since a default only seeds an account that has none -- and readers
may still turn it back off afterwards, which enforcement does not allow. The
difference between the two is entirely in the remembering.
Each change carries its own version, and an account stores the ones it has had
in its own settings file. Ids rather than a high-water mark, so a change dated
earlier than one already applied is not silently skipped -- the reporter's
analogy is a schema migration, and this is that shape.
Per account rather than per device, because ihasmail's settings are not
browser-local: they live in a file in the reader's own JMAP Files, with the
browser holding a cache. Signing in on a phone does not apply everything a
second time.
A change reaches somebody who had already decided otherwise. That is intended
and confirmed on the issue: the point is to reach everybody who is already
here. It is applied once, and their next decision sticks.
One `update` for however many are pending, since each would otherwise push a
settings file of its own. Enforced values still win, being applied after. A
change whose settings this build does not have at all is dropped rather than
recorded, or it would never run on the ihasmail that does have them.
The reader is told. A setting moving under somebody without a word is the part
of this worth being uneasy about, so the count is toasted with a way into
Settings.
README gains the Docker half the user asked for: a mounted policy file, the
same thing as environment variables for a deployment with no volume, a compose
fragment, and the fact that a policy is read once at startup so editing it
means a restart.
Closes#207.
The first two thirds of #207. A school wanting "warn about outside senders"
on for three thousand pupils cannot ask three thousand pupils, and the
reporter is right that this is a company policy rather than a preference.
Two powers, and the difference between them is the whole request. `defaults`
seed an account that has never had settings of its own and can be changed
afterwards like anything else -- a starting point, not a rule. `enforced` are
reapplied on every load and cannot be changed at all.
Enforced controls stay visible and go dead, with a line saying why. The issue
asked for that by name: a control that is simply missing reads as a bug to
somebody who has used ihasmail without a policy.
The lock is in the settings store rather than only on the controls. There is
one door -- `update` -- and putting it there means an imported settings file,
a settings file synced from a device that predates the policy, and a control
somebody adds later and forgets to check are all covered by construction.
Reset goes back to the installation's answer rather than to ihasmail's, so it
cannot be a way around a policy either.
Configured by environment variable or by a file, because ihasmail's own
production runs read-only with no volume: an installation that cannot mount a
file can still set a variable. Keys this build does not have are dropped, the
same rule an imported settings file already gets -- a policy written against a
newer ihasmail must not put a setting nothing reads into everybody's synced
settings file. Malformed JSON stops the server rather than quietly doing
nothing, since a policy that silently did not apply is indistinguishable from
the feature not working.
Tier three -- enforcing a setting once while still letting readers change it
afterwards -- is not here. It needs a decision the reporter and I have not
made yet, and it is the only part that stores anything new.
Refs #207.
Somebody deciding whether to spend an evening on a mail client wants to
see it before they read about it. The link sits under the logo, above the
badges, because a demo answers the question the badges are only evidence
for.
The line under it says what the demo is -- invented mail, nothing kept --
so nobody arrives expecting to sign in with an address of their own.
The mail bullet in "What's in it" listed everything a message can turn
into -- a reply, a filter, a receipt -- except the newest one. It now
names the event too, and says the guests come with it, which is the half
that is not obvious from the feature's name.
The picker offers English plus nine translations. I wrote it up as nine
in total with eight unread, which is off by one in the direction that
undercounts the work and, worse, misstates how many catalogues are
waiting for a speaker to read them.
Nine of the ten are machine-made and unread — all of them, not all but
one — so the sentence that matters reads more sharply than the wrong
version did, not less.
The translations shipped today and the docs still said "no languages but
English". They also need to say the harder thing, which is that eight of
the nine have never been read by anybody who speaks them.
- README gains the language list, with the Beta caveat in the same line
rather than a footnote.
- FEATURES.md gains an Interface language section: the list, why it is a
separate setting from the date locale, and the two design properties
that follow — a missing entry renders English, and plurals are asked of
Intl.PluralRules rather than assumed, which is why Russian carries
three forms and Japanese one.
- ROADMAP.md no longer lists translations as "not yet". What replaces it
is the half that is genuinely not done: a translation anybody has
checked. RTL is split out as its own entry, because holding Arabic,
Hebrew and Persian back is a layout decision and not a queue position.
- KNOWN-ISSUES.md gains two entries. The unread catalogues, which is the
one thing on that page that cannot be closed by testing. And the
coverage number that read 100% while two hundred strings rendered
English in every language — recorded as a general lesson rather than an
i18n one, since a coverage number measures what it can see and the rest
is exactly what nobody is checking.
ihasmail's mail list was built for a mouse. A row is clicked, right-clicked
and dragged into a folder, and on a touchscreen two of those three do not
exist -- so the phone layout had the shape of a mail app and none of the
handling, and the things people reach for first simply did nothing.
Four gestures, all touch-only, so a mouse keeps drag-to-folder unchanged:
- Swipe a row sideways to act on it. Each direction is a setting -- right
archives and left deletes by default, matching the app the phone came
with -- and the strip revealed behind the row names what will happen in
the folder it is happening in: "Delete forever" out of Deleted Items,
"Not spam" inside Junk Mail, and nothing at all where the action is a
no-op, in which case the row will not move that way.
- Hold a row to select it. Selection was reachable already, by aiming at a
checkbox beside an avatar, which is not how anyone selects mail on a
phone. The selection toolbar gained an overflow menu at the same time:
report spam, mark unread and label were hidden on narrow screens and had
nowhere else to be, so touch selection could not reach them at all.
- Hold a folder for the menu its ⋮ button opens.
- Pull the list down to refresh, and drag in from the left edge of a
conversation to go back. The toolbar's button and arrow both stay: a
gesture with no visible control is one only the people who already know
about it can use.
The arithmetic behind them is in lib/touch.ts, away from the components and
under test, because the numbers are the whole thing: an axis lock biased
towards the vertical, so a diagonal flick stays a scroll rather than
deleting whatever it passes over.
Two layout bugs turned up while checking this on a 390px screen, both
older than the gestures. The app shell is a grid with only its rows named,
so it took an implicit auto column sized to the top bar's min-content --
about 470px -- and every message row ran off the right of the glass with
its date beyond the edge. The column is now stated as minmax(0, 1fr), and
the search field is allowed to shrink. Full-screen surfaces measure in dvh
rather than vh, and the tab bar, drawer and compose button keep out from
under the notch and the home indicator.
The feature list lived in three places that each answered a different question: the site sells it, the docs teach the parts that surprise people, and the README summarises both in a paragraph. Nothing said, in one place and at full detail, what is actually built -- so evaluating ihasmail meant reading the source, and a feature that quietly stopped working had nowhere to be contradicted.
FEATURES.md is that inventory, written from the code rather than the copy: the capability matrix and what each missing one removes, the search operators as the parser actually reads them, the Sieve tests and actions, every settings section and which of them follow the account, the shortcut set, the security posture, and the environment. Where a behaviour is odd it says why, because the reason is usually the stateless constraint.
It also records what the docs had not caught up with: single-occurrence edit and delete are built, and the id renumbering that made them hard is described where someone changing that code will find it.
Both the versioning comment and the README described 1.0 in the past
tense, which reads as though it has shipped and the numbering was
changed in response. It has not. The old `2.16.x` scheme was dropped
over where it would end up, not where it ended up, and the surrounding
conditionals are simplified to match: "would sort", "would read", rather
than "would have".
The badge quoted in the comment also said 0.16.19; it says 0.16.20 now.
Comments and prose only -- no behaviour changes.
INBUXA moved 0.16.19 -> 0.16.20 on 2026-08-31 with eight seconds of
downtime. A 0.16.x -> 0.16.x upgrade is a binary replacement: no data
migration, no config change.
Nothing ihasmail depends on changed. The session capabilities, blob,
quota, submission and registry paths are untouched by the release, and
`urn:stalwart:jmap` is still absent at session level, so the three-place
lookup that sign-in turns on remains both correct and necessary. The
Locale enum did move from POSIX names to BCP-47 (`en_US` -> `en-US`,
`POSIX` dropped), which `normalizeLocale` already handled.
The recurrence entry is rewritten rather than deleted. 0.16.20 added
`CalendarEvent/set` support for synthetic ids, so per-occurrence editing
is a thing the server allows and ihasmail does not do yet (#132) - and
the refusal that used to catch a synthetic id reaching `destroy` is gone,
which is why the base-id resolution wants moving into the store (#133).
Dates on the existing entries are left at 0.16.19 on purpose: they record
what was actually run, and the upgrade was read from the diff, not re-run.
The repositories moved off LINUXexpert-org. The old URLs redirect, so
nothing was broken, but a redirect is not a correct address to publish.
The SOURCE_URL defaults matter most: the AGPL asks whoever runs a
modified version to offer that version's source, and the sign-in page and
About screen show this link. It is in four places that have to agree --
the compose file, .env.example, the server default and the web fallback.
The rest is documentation and issue links.
The middle field was the Stalwart generation a build targeted -- 16 for
0.16 -- which leaves nowhere to go when Stalwart reaches 1.0. There is no
honest value for it: 2.1 sorts below the 2.16 already deployed, so every
image and About screen would have read as a downgrade. Tying our
numbering to somebody else's was the mistake, and which Stalwart a build
needs is said properly in the README badge and KNOWN-ISSUES, where it can
be precise rather than one digit.
The version is now the date of the commit it was built from, and the pull
request moves after the + as build metadata. It is provenance rather than
a rank: at the rate they merge here it climbs without bound and says
nothing about how new a build is. Everything after the + is ignored when
versions are compared, which reads correctly -- two builds from the same
day differ in where they came from, not in age -- and nothing depends on
that comparison anyway, since images are pruned by creation time and a
rollback names a git ref.
The date is the commit's own, so rebuilding an old commit gives the
version it had the first time. package.json is no longer the source of
anything and sits at 0.0.0, which is what an unversioned build reports
and is meant to look wrong.
The formatting is a pure function now, so the rules have tests. They had
none while the version was the thing naming every image we ship.
Two lines in the README: the licence statement, and the "by" badge in the
header.
The badge mattered as much as the copyright line. It pointed at
linuxexpert.org, which is now retired -- it 301s its articles to jcoffey.dev
and answers 410 for everything else -- so "by LINUXexpert.org" sent a reader to
a site that no longer claims this work. It now reads "by Coffey Labs" and
points at coffeylabs.org.
Everything else that says LINUXexpert-org is a github.com URL: the source link
baked into .env.example, docker-compose.yml, web/src/lib/source.ts and
server/src/config.ts, plus issue and release links in the docs. Those are the
repository's real path and are unchanged -- the AGPL source offer in the app
depends on that URL resolving.
LICENSE untouched. Its only copyright is the FSF's on the AGPL text itself.
"Gmail-class webmail" describes the client, and every webmail says something
like it. What no other webmail for Stalwart says is that the container has
nothing to persist: one optional write path, and with IMMUTABLE=1 switched on,
no volume and no writable root filesystem at all.
The Gmail comparison still earns its place -- it is what tells someone what the
client feels like to use -- so it stays, one clause later, where it describes
the app rather than the product.
The server writes to one path and no other: SESSION_FILE, from sessions.ts.
Everything else it touches on disk it only reads. So a container with a
read-only root filesystem already works -- except that `VOLUME ["/data"]`
quietly undid it. Docker acts on that directive: a container started without
`-v` gets an anonymous volume mounted there anyway, writable even under
`--read-only`. It persisted nothing across a redeploy, since each new container
got a fresh empty volume, and it left an orphan behind every time one was
replaced. Deployments that want the sessions to survive already say so
themselves -- docker-compose.yml and deploy.example.sh both mount a named
volume -- so removing the line changes nothing for them.
IMMUTABLE=1 asserts that this is how the instance is running. It is checked
rather than believed: the server refuses to start if SESSION_FILE is still set,
or if the filesystem it is installed on turns out to be writable. Left
unchecked the misconfiguration is silent, because persisting sessions is
best-effort -- a read-only /data costs one warning at the first sign-in and
nothing more until the instance is replaced and everyone is signed out.
SessionBackend names what the rest of the server asks of a session store, and
`sessions` in app.ts is typed as it. Nothing changes today; SessionStore is
still the only implementation. It is there so the OAuth work is written against
the interface rather than the class, and so the interface can record which of
its methods a stateless backend could satisfy alone: create, resolve, reseal
and destroy each touch one session, while listForUser and destroyAllForUser
have to reach sessions other than the caller's. The second of those carries the
guarantee that changing a password invalidates the sessions still holding the
old one, which is why it needs a registry -- Stalwart's token registry, once
sign-in goes through OAuth.
Slimming it left no way to try ihasmail without leaving GitHub. Restored in
short form -- the four commands, the 2FA app-password note, and links to the
install and configure guides for TLS and the full environment.
ROADMAP.md's 2FA entry points back at that section again, rather than at the
install docs it was redirected to when the section was gone.
The README had grown to 330 lines and was carrying three audiences at once:
installing, using, and working on ihasmail. docs.ihasmail.org covers the first
two now, and ihasmail.org covers the feature tour, so the README links there
instead of restating them.
- Known issues / pending QA → KNOWN-ISSUES.md, verbatim
- Roadmap / not yet → ROADMAP.md, verbatim (its "see Quick start" pointer now
aims at the install docs, since that section is gone)
- Dropped the env-var table (docs.ihasmail.org/configure/), the shortcut list
(/shortcuts/), the Docker quick start (/install/) and the long feature list
(ihasmail.org/#features), leaving a nav table at the top and a six-line
summary of what's in it
- Kept and tightened what is only true of this tree: architecture, dev
commands, the mock, version numbers, deploying
- Version examples refreshed from 2.16.57 to the current 2.16.84
Settings > Security & sessions still had the full enrolment flow --
QR code, secret, "Set up" -- for something that cannot be signed in
with. Turning 2FA on there took a working account and made webmail
unreachable from any device not already signed in, because ihasmail
has nowhere to send a TOTP code: Stalwart accepts one through an
OAuth flow alone and offers no password grant (#75). The one mercy
was that enabling reseals the current session onto a fresh app
password, so the browser doing it stayed in -- and the next sign-in
elsewhere did not.
So the enrolment path is gone until sign-in with a code works.
Turning 2FA *off* stays. It is a plain registry write, it was
verified live on 0.16.19, and anyone already enrolled -- here or in
Stalwart's own settings -- needs a way back. That control is now the
whole section, and it appears only for an account that has 2FA on;
everyone else no longer sees the heading at all.
The password form keeps its authenticator-code field on the same
condition, since Stalwart demands a code on every credential write
once 2FA is on.
Nothing changes on the server: /api/account/2fa/begin and /enable
are untouched and still tested, ready for the OAuth work that makes
them usable. The sign-in page's code field is also untouched -- it
already explains itself and points at app passwords.
README no longer advertises enrolment by QR code, and the roadmap
entry says which direction the setting still moves.
An account using a unique address per service, on a server with an alias
domain, ends up with every local part twice over and a From picker
nobody can use -- while only ever sending from a handful (#73).
Identities can now be hidden from that picker, from Identities &
signatures. Hiding is presentation only: the identity still exists,
still receives, and stays listed and editable, the way an unsubscribed
folder is still a folder. That framing is mbunkus's own, and it is the
right one -- this is a UI preference, not a change to the account.
Three things it refuses to do, because a sender picker with nothing
usable in it is worse than a cluttered one:
- it will not hide the identity a draft is already using, which would
leave the select with no matching option and move the From line
under the writer
- it will not hide the default, which is what a new draft starts on;
the button is disabled there and says why
- if every identity is somehow hidden -- reachable only through
settings sync, since the UI will not do it -- they are all offered
again
The setting syncs, so the picker looks the same on every device, which
follows from DEVICE_KEYS being a list of exceptions rather than a list
of what travels.
Verified against the mock with four identities and one hidden: the
picker offers the other three, the hidden address is gone from
composing, the default's hide button is disabled, and the row says the
identity still receives.
Signing in with a two-factor code failed with a bare 401 and "Invalid
credentials", which sent the user off to check a password that was
perfectly good (#75). It cannot work, and the app already knew.
Stalwart accepts a TOTP code only through an OAuth flow -- its own web
interface is an OAuth client, which is why signing in *there* succeeds --
and it offers only the authorization-code and device flows. There is no
password grant, so a client holding a username and password has nowhere
to exchange them plus a code for a token. The concatenated
`password$code` form this README claimed was accepted is not a route the
server has, and appears never to have been. What was verified live on
0.16.19 was enabling and disabling 2FA, never signing in with a code.
The contradiction was already in the codebase: turning 2FA *on* mints an
app password and reseals the session onto it, precisely because a plain
password stops working from that moment. The sign-in page was the one
place still assuming otherwise.
Three changes, no new capability:
- A 401 on a sign-in that carried a code now says what is happening
and where to go instead, and says the password is probably fine.
A sign-in without a code is untouched, so an ordinary typo still
reads as an ordinary typo.
- The field stays, and is honest about itself. Removing it would leave
someone with 2FA finding nothing at all, which is worse than finding
a field that explains the situation and points at app passwords.
- The README's claim is corrected rather than quietly dropped, and
real 2FA support is written into the roadmap as what it is: an OAuth
implementation, handing sign-in to Stalwart and holding a refresh
token instead of a sealed password.
Web Push works, confirmed end to end against the live 0.16.19: with
Chrome open and every ihasmail tab closed, a notification arrives
immediately and names the sender and subject.
But "closed" means ihasmail, not the browser, and the switch did not say
so. Web Push is delivered over a connection the browser holds, so
something of it has to be running.
Observed on 2026-08-26, with Chrome fully quit and "Continue running
background apps" off: nothing arrived until Chrome was started again, at
which point the queued notification was delivered. Turning that setting
on keeps a process alive and restores immediate delivery.
Worth knowing that the queue is not indefinite -- a Web Push message
carries a TTL, and one that expires before the browser comes back is
dropped rather than delivered late. Being an installed PWA does not
change any of this on a desktop; it changes the window, not who holds
the connection. On Android it would, since the push service can wake the
browser from cold.
None of this is ihasmail's to fix. It is what Web Push is, and the only
thing worth doing about it is not implying otherwise -- which the
notification switch was quietly doing.
ihasmail's notifications came from EventSource, which lives exactly as
long as a tab does -- so "desktop notifications" has always quietly
meant "while you are looking". That switch is now labelled as much, and
a second one does the thing people assumed the first one did.
Stalwart 0.16 signs Web Push with VAPID (RFC 9749) and can put the
message itself in the payload (draft-ietf-jmap-emailpush). The server
pushes straight to the browser's own push service: ihasmail's server is
not in the delivery path, there is no relay to run, and nothing beyond
the browser vendor's endpoint that Web Push requires of everyone.
Checked against the live 0.16.19 before any of this was written, because
an advertised capability is not a configured one:
- the session publishes a real applicationServerKey, so no key
generation or server configuration is needed
- PushSubscription/get answers an ordinary user rather than refusing
- emailpush is advertised, and its draft defines a filter, an ordered
properties list and an urgency -- so the payload can carry sender and
subject, and the server drops properties from the end when it will
not fit rather than failing the notification
Three things this gets right that are easy to get wrong:
- The verification handshake. A JMAP subscription delivers nothing
until the client echoes back a code the server pushed, and the
service worker cannot answer it -- no credentials in that context.
It forwards the code to a tab, or leaves it in the cache when no tab
was open to forward it to.
- Key encoding. The W3C Push API produces unpadded base64url and
Stalwart 0.16 was fixed to accept exactly that, so nothing here pads
on the way out. The VAPID key needs padding on the way *in* for
atob; getting that backwards fails at subscribe() with an opaque
error, so it lives in one named function with tests.
- Sign-out. A subscription belongs to the account, not the session.
Without tearing it down, a shared machine keeps notifying for a
mailbox nobody is signed into -- which is somebody else's mail.
The mock models the JMAP half, including refusing padded keys and
non-https endpoints, and creating subscriptions *unverified*. Delivery
cannot be mocked -- it runs through the browser vendor's real push
service -- but a mock that marked a subscription verified on creation
would let a client ship without the handshake, and the symptom in
production is "registered, and silent".
Not verified end to end: an actual notification arriving. That needs a
real browser, a real push service and real delivery, so it is live
testing or nothing.
Every deploy tags an image with its version, which is what makes a
rollback a `docker run` rather than a rebuild -- and what has quietly
put 7.7 GB of them on the host, 4.3 GB of it reclaimable. `docker image
prune` will not help: they are tagged, which is the whole point of them.
The script that creates them now removes them, keeping the newest
IHASMAIL_KEEP_VERSIONS (three by default, 0 to keep everything).
Two things it is careful about, both learned from what could go wrong
rather than from something that did:
- it runs only after the new container reports healthy, so a rollback
target is never dropped while the thing meant to replace it is still
unproven.
- it excludes the image the container is actually running, by asking
docker what that is rather than assuming it sorts newest. After a
rollback it does not: the running image is an old one, and naive
keep-the-newest-N would delete the image in use. Docker would refuse,
but being refused is not the same as not having tried.
Exercised against a stubbed docker on PATH, so the pipeline and xargs
are the real ones: keeps 3 and removes the oldest, keeps 1 and removes
three, keeps everything at 0, never lists :current, and spares the
running image in a rollback where it is the fourth-newest.
Also records delete-all-spam as confirmed live on 0.16.19 today: Junk
Mail emptied and Deleted Items stayed empty afterwards, which is the
half a mock cannot prove and the half the feature exists for.
Junk Mail can now be emptied in one action, the way every other mail
client offers it: a banner across the top of the folder, and an item in
both the folder's right-click menu and the list's own menu.
The messages are destroyed rather than moved to Deleted Items. Routing
spam through the bin on its way out leaves you with the same problem in
a different folder, and "delete all spam" means gone everywhere else. So
the dialog says it before you commit, and there is no undo.
emptyMailbox already did the hard part -- walking a folder a page at a
time so it survives maxObjectsInSet, which a Deleted Items of 5192 once
did not. All that changed is which folders it will accept. The guard
stays in the store rather than living only in the menus, so a fourth
caller cannot empty the Inbox by asking nicely.
The three entry points share one helper, because three dialogs warning
about a permanent deletion in three slightly different ways is how one
of them ends up not warning at all. A folder with nothing in it offers
the item greyed out rather than hiding it, so it is where you expect it
to be next time.
Folder naming is fixed in the same commit because it changed the same
file, and because testing this is what surfaced it. Two problems, one
cause:
- The mock called its folders "Trash" and "Sent". Stalwart's defaults
follow the Exchange convention -- "Deleted Items", "Sent Items" --
so anything built from a folder's name read differently against the
mock than against a real server, and every screenshot in the README
showed a folder list no user has.
- Worse, and in shipping code: the undo toast took a hardcoded label
in preference to the folder's actual name, so deleting a message
announced "moved to Trash" on a server whose folder is called
"Deleted Items", and reporting spam said "moved to Spam" where it is
"Junk Mail". The one message whose job is saying where mail went was
naming somewhere that does not exist. It now prefers the mailbox's
own name and keeps the hardcoded word only as a fallback.
Verified against the mock: the banner appears only in Junk and only with
something to delete, the dialog counts and pluralises, the messages are
destroyed and Deleted Items stays empty afterwards, the banner
disappears once the folder is, the item greys out when empty, Archive is
offered neither, and Trash still says "Empty Deleted Items".
A dark theme carrying ihasmail.org's palette: a teal-navy ground rather
than the blue-slate of the plain dark theme, with the orange the logo's
cat is drawn in doing the work of the star and the warning colour. The
values are the site's own, read from its stylesheet rather than picked
by eye.
It is a theme rather than an accent because it changes backgrounds,
borders and text as well as the highlight -- an accent could not.
It rides on data-theme="dark" and adds data-palette="ihasmail" on top,
so the eleven dark-only rules further down the stylesheet keep applying
without being duplicated for a second dark theme. Specificity then does
something deliberate: the palette block is 0,2,0 and the accent variants
are 0,3,0, so a chosen accent still wins over it -- and because the
default accent ("teal") has no rule of its own, ihasmail.org's accent is
what shows until someone picks another. Verified both ways in a browser.
It is now what a new account starts on, so the app looks like itself
before anyone has chosen anything. Only a default: a stored theme always
wins, which leaves everyone already using ihasmail where they are, since
the setting is saved whether or not they deliberately picked it.
While here, the theme-color meta tag was fixed. There were two, both
carrying media attributes, and applyTheme looks for
:not([media]) -- so it matched neither and the browser chrome had never
followed the chosen theme at all, only what the OS preferred. One tag
now, updated from JS, starting at the default theme's background so the
first paint is right too.
Contrast measured rather than assumed, against the theme's own
background: text 14.5:1, muted 8.6:1, faint 6.4:1, accent 8.0:1, link
9.8:1, star 7.9:1, accent-on-accent 8.4:1. All AA or better.
The four things this section still had outstanding were all exercised
against the live 0.16.19 on 2026-08-26:
- read receipts, end to end -- assembled, uploaded, imported, submitted,
landed in Sent, and $mdnsent set so a second look does not offer to
send another. Only the mock had seen this before.
- the rest of the scheduled-send journey: a hold expiring and being
delivered, and the Scheduled folder reconciling on the way in -- a
released message to Sent, a cancelled one back to Drafts. The hold
itself was already confirmed on 2026-08-25.
- Files rename, move and delete on the 0.16 path. What had been
confirmed on 0.15.5 was the older code path, and that no longer
exists, so this closes the entry rather than adding to it.
- the settings file on the deployed instance, rather than only on a
build that had not shipped yet.
The section keeps saying what was checked and when, and now says why:
it has been wrong before, when the 0.16 registry path was recorded as
verified live while a capability looked for in the wrong place meant it
had never run at all. What is left here is no longer a list of unknowns
but of things worth knowing -- where Stalwart departs from a spec, where
a setting has to be on for a feature to work, and what ihasmail
deliberately does not do.
The live deploy script has never been under version control, which makes
it the one part of the pipeline that can silently fall out of step with
the repo -- as it just did: it builds without --build-arg
IHASMAIL_VERSION, so every deployment would report 2.16.0 no matter what
was actually built.
This is that script with the host taken out of it. Every path, name,
port and volume is a variable with a default that describes the shape of
a deployment rather than any particular one, so what is published is the
logic and none of the topology. It follows Caddyfile.example and
nginx.example.conf, which are here for the same reason.
Nothing sensitive was in the original either -- it never reads the
environment file, only hands the path to docker run --env-file -- but
the absolute paths named a user and a directory layout, and there is no
reason for those to be public to buy version control over the guards.
Two things it does that the original did not:
- passes the version to the build, which is the whole reason this came
up. A Docker tag may not contain "+", which a version for a commit
that arrived outside a pull request does (2.16.57+g1fa6578), so the
tag turns it into "-" while the build is told the real form. About
and /api/health still report it correctly.
- tags each build with its own version as well as :current, so rolling
back is running the previous tag rather than rebuilding it. The
failure path lists what is there to go back to.
Exercised against a throwaway clone, container, volume and image:
the hold guard refuses a held commit that production does not already
carry and lets one through that it does; the confirmation guard refuses
to run over a pipe without --yes; a dry run stops before building; and a
full run built, replaced the container and reported healthy at
2.16.57+g0549a09 -- from an image tagged 2.16.57-g0549a09, which is the
sanitising working.
It was only on the About page, which is behind a sign-in -- so the one
place a version is most often wanted, when something is wrong and nobody
can get in, was the one place it could not be read.
It sits next to the AGPL source link deliberately. Section 13's offer is
for the source of *this* build, and naming the build is what turns that
into something a person can act on rather than a link to whatever main
happens to be. A bug report can name the build without signing in, too.
Worth being deliberate about: this is pre-authentication, so anyone who
can reach the instance can read it. That is a real disclosure -- it tells
an unauthenticated visitor exactly which build to look up. It is being
accepted rather than overlooked: ihasmail is AGPL with its source already
linked from that same line, so the version narrows nothing that reading
the source would not, and the sign-in page already names the software.
ihasmail called itself "2.0" on the About page and "2.0.0" from
/api/health, both hardcoded, in four places that had drifted from each
other and from anything meaningful. A build now says what it is:
ihasmail v2.16.57
| | |
| | the pull request the commit came from
| the Stalwart generation this build targets -- 0.16
ihasmail's own major
The first two are the version in the root package.json, so there is a
single place to bump them, and 16 becomes 17 when ihasmail moves to
Stalwart 0.17. Dropping 0.15 is what makes that middle number honest:
while two generations were supported it could not have been either.
The pull request number comes from git at build time and is never
written back into the tree. It cannot be: it does not exist until the
pull request has merged, so a committed version would always describe a
merge that had not happened yet, and every open branch would collide on
the same line. A commit that did not come through a pull request carries
the last number plus its own short SHA -- 2.16.57+g1fa6578 -- which says
it is past that pull request rather than quietly claiming to be it.
.dockerignore excludes .git on purpose, so an image build cannot work
any of this out. It takes --build-arg IHASMAIL_VERSION instead, which
the build stage bakes into the bundle and the runtime stage keeps as an
environment variable for the server. Left out, it falls back to the base
version from package.json rather than failing -- so a version with no PR
number on it means whoever built the image did not pass one.
scripts/ is copied into the runtime image because the server resolves
its version through it. There is no git in there to ask, which is the
fallback's whole purpose.
Verified: 2.16.57 in the bundle and from /api/health on a dev checkout;
the same after a real docker build --build-arg, from inside the
container; and 2.16.0 rather than a crash when the arg is left off.
Note for deploying: ihasmail-deploy.sh on the host builds without the
argument and will produce 2.16.0 until it passes
--build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)".
ihasmail spoke to two generations of Stalwart that are less alike than
their version numbers suggest: 0.16 replaced the REST management API with
JMAP registry objects, changed the shape of FileNode, split its rights
up, and moved configuration into the store. Carrying both meant 34 branch
points across nine files, a 92-line compatibility shim whose only job was
telling them apart, a parallel REST implementation of every credential
operation, and a mock that had to model both.
The branches were not the real cost. The cost was that a wrong answer
about which generation had answered always had somewhere to fall back to,
so it failed quietly rather than loudly: one capability looked for in the
wrong place downgraded every real 0.16 server onto the 0.15 path, which
posted the current password to an endpoint 0.16 had removed, reported the
wrong generation on About, and ran Files on the older code. It reached
production and was recorded as verified when it was not. The mock
mirrored the same wrong placement, which is why the tests agreed.
Removed: the filenode compatibility shim, the dual "registry" | "legacy"
backend in account.ts, the pre-0.16 generation in AccountInfo and
everything that read it, the mock's LEGACY mode and dev:mock:legacy, and
the three test files that existed only to pin 0.15 behaviour.
Sign-in now refuses an older server by name, once, rather than letting
Files, the account locale and credentials each fail in their own way with
nothing connecting them. It says the credentials were fine -- someone
hitting this has typed a correct password, and telling them otherwise
sends them round in circles -- and names the tag to build from. Four
tests cover it, including that no session cookie is minted and that bad
credentials on such a server are still a plain 401.
Two fallbacks went that were not strictly about 0.15, and both for the
same reason the removal is happening. Files no longer answers a refused
filter or sort by fetching every node in the account, which would hide a
real fault behind a performance cliff nobody would notice. And the app
folder lookups 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 rather than being ignored.
The last release that runs on 0.15 is tagged stalwart-0.15-support.
Verified against the mock end to end: sign-in, the Files tree on the 0.16
path with the app folder hidden, and self-service credentials over the
registry. 226 web + 75 server tests pass; typecheck and build clean.
Settings moved into the account's JMAP Files in #55, which shipped to the
live 0.16.19 today. The README carried the feature but not what had
actually been checked against a real server, which is the distinction
the Known issues section exists to keep.
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 start with
an empty localStorage, so each of them read the account's file rather
than anything cached locally -- which is the whole claim.
That incidentally exercised part of the 0.16 Files path the section had
flagged as still wanting a look on its own terms: finding and creating
the folder, creating a node with nodeType, uploading and downloading its
blob, and pointing an existing node at a new one. Rename, move, delete
and the Files view itself are still unchecked there, and the entry now
says so rather than letting the tick spread further than the evidence.
Every setting lived in localStorage, so none of them travelled between
devices. The sharpest edge is the default identity: with none set the
address that sorts first wins, so mail goes out from an address the
recipient may not recognise -- and someone who sets it at work finds it
unset at home, with nothing to say so. Reported in #54.
They now live in a settings.json in the account's own JMAP Files, beside
the signature images already kept there. ihasmail itself stays stateless:
no volume, no database, nothing to back up separately, and the settings
are covered by whatever backs up the mail store.
localStorage stays as a cache rather than the source of truth, so the
first frame is painted from it and the file corrects it a moment later.
A private window has no cache and shows defaults for that one frame,
which is the trade for not gating the whole app on a network round trip.
Not everything should follow the account. A list-pane width picked on a
27" monitor is wrong on a laptop, and the notification toggles track a
permission the browser grants per-device, so claiming it elsewhere would
be a lie. Those stay local, written as a list of exceptions so that a
setting added later syncs by default -- which is what adding one almost
always means.
Writes are coalesced: update() fires on every frame of a splitter drag,
so a change waits 3s and the newest value wins. A tab going away flushes
first, as does signing out, so a setting changed seconds before either
is not lost.
The ihasmail folder is now hidden from the Files view, contents and all.
Hiding the folder alone would have been worse than showing it: the tree
attaches a node whose parent is missing to the root, so the signature
images would have spilled into the top level as if the user had put them
there. Those images have been visible since signatures shipped.
Requires 0.16 -- FileNode/query cannot see directories before that. On
0.15 settings stay local exactly as they were.
Verified against the mock end to end: folder create, blob upload, node
create, read back, update, re-read. Not yet exercised against the live
0.16.19.
Every screenshot still showed the old mark in the topbar -- squashed,
with an illegible ".com" smudge under it. All seven are retaken against
the mock server at the original framing (1420x703, mobile 500x703).
The four Sieve rules in filters.jpg are not seeded by the mock, so they
are rebuilt through the rule builder to match the previous shot:
Newsletters, From the boss, Receipts, Build failures.
Date format is pinned to the locale default so the list reads "Aug 24"
as it did before, rather than the "24.08." the stored preference had
drifted to.
README: the header logo drops 180 -> 150 wide, since the mark alone is
portrait where the old artwork was landscape, and the note about the
prototype now says the logo has lost its wordmark too.
Down to the licence, the Stalwart generations this has been run
against, and a LINUXexpert.org badge linking to the site. The CI, JMAP,
Node, TypeScript and no-IMAP badges are gone.
The Stalwart and LINUXexpert.org links carry target="_blank". Worth
knowing that GitHub itself will ignore it: its README sanitiser strips
target and rewrites rel to nofollow, which was checked against the
markdown API rather than assumed. The attribute still does its job
anywhere else the README is rendered, so it stays.