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.
Seven, each saying something true about this project rather than
decorating the page: CI, the licence, which Stalwart generations it has
actually been run against, the JMAP RFCs it implements, the Node floor
from engines, that both tsconfigs are strict, and the line the README
already uses to describe itself -- no IMAP, no SMTP, no database.
Every claim was checked against the repository: the CI badge is the
workflow's own, the licence matches LICENSE and package.json, the Node
version comes from engines, and strict is set in both tsconfigs. All
seven were fetched and rendered before committing, so none of them is a
broken image.
The standalone licence badge lower down goes, being now the second one
on the page.
The Stalwart badge is the one to remember: it is static, so it needs
changing when the live instance moves, exactly as the prose above it
does.
Three things a licence audit turned up. None of them is a conflict --
every one of the 182 installed packages is permissive, and the relicence
was within the copyright holder's gift -- but all three are ways the
AGPL fails to stick.
The offer was hard-coded to this repository. Section 13 asks whoever
runs a modified version to offer *that* version's source, so every
deployment with a patch in it was pointing at the wrong tree, and would
have gone on doing so unless its operator noticed and edited the About
page. SOURCE_URL now sets it, alongside APP_NAME, and both the sign-in
page and About read it.
The offer was also only visible after signing in. Whoever is looking at
the sign-in form is interacting with the program over a network too, so
the footer carries it now.
And the two workspace packages declared no licence at all. Private, so
npm never minded, but anything reading the tree saw a blank where the
rest of the project says AGPL-3.0-or-later.
Checked both ways round: with SOURCE_URL set to a fork, the sign-in page
and About both point at the fork; with it unset, both fall back to this
repository.
ihasmail is webmail: it is nearly always run as a network service rather
than handed to anyone as a binary, which is the case the plain GPL does
not reach. The AGPL's section 13 does — anyone running a modified
ihasmail for other people has to offer them its source.
LICENSE is the full AGPL-3.0 text from gnu.org. The SPDX identifier
changes from GPL-3.0-or-later to AGPL-3.0-or-later in package.json, the
lockfile's own entry for it, and the About screen. Old commits and tags
are left exactly as they were; this is the license from here on.
Scheduled send had only ever been exercised against the mock, and the
one thing that could not be taken on trust was whether the MTA honours
the hold at all: with futureRelease off it takes the HOLDUNTIL, drops
the hold and sends at once, saying nothing.
With the setting turned on at 30d, a submission ten minutes out came
back pending, sendAt equal to the time asked for, queued at the MTA.
The capability stays worthless as evidence — it advertised
maxDelayedSend: 2592000 and FUTURERELEASE throughout, including while
the setting was off. Noted, because it is the obvious thing to check and
it lies.
Still mock-only, and now said so precisely: the Scheduled folder
reconciling on the way in, and a hold expiring into a delivery.
Adding a participant by patch had failed earlier, which left a question
over RSVP, since that patches participants/{key}/participationStatus.
It works, comment and all, and so does adding guests to an event that
had none and clearing them again with null.
The patch has to name the base event: a synthetic id is refused with
"Updating synthetic ids is not yet supported", which is exactly why rsvp
resolves baseEventId first. Worth writing down before someone simplifies
that line away.
The store side was proven earlier; the sending side had only been
reasoned about. An invitation went to an external Gmail address from the
live 0.16.19: it arrived as an invite card, the decline came back, and
Stalwart applied it to the event — needs-action to declined, sequence 1.
Cancelling notified the guest as well.
Guests added to an event vanished on save and no invitation was ever
sent. Not a guard in the editor, and nothing the server complained
about: ihasmail addresses a participant the way RFC 8984 does, with
sendTo and email, and Stalwart 0.16 keeps that address under
calendarAddress. Handed the RFC's spelling it stores the event, drops
the entire participant map, and reports success. Six shapes were tried
against a live 0.16.19, down to sendTo and roles alone; all six were
dropped, and patching a participant onto an existing event fails
outright with "Patch operation failed".
The same disagreement runs through two more properties. The organizer is
organizerCalendarAddress, not replyTo. A recurrence is a single
recurrenceRule, not a recurrenceRules array — and that one Stalwart
refuses honestly, with invalidProperties, so no recurring event could be
created at all and existing ones showed no repeat.
So writes now use Stalwart's names and reads accept either, since a
mailbox may hold events written by other clients. The mock now refuses
what the real server refuses and drops what it drops: advertising the
RFC spelling is exactly how this reached a live server unnoticed, the
same way the capability-placement bug did.
Verified against 0.16.19: participants, organizer and rule all survive a
create, an update and a re-read, with the roles kept as sent.
Fixes#26Fixes#30
Self-service credentials work against the real server: password changes, 2FA
and app passwords, over the registry rather than the REST endpoint 0.16
removed. That also settles the generation lookup, which About and Files read
through the same helper.
Files is the one thing this does not settle. The earlier live run recorded
against 0.16.19 exercised the pre-0.16 path -- correct behaviour for what
ihasmail then believed the server to be, but not the path it takes now. Said
so, rather than letting an old confirmation stand for a different code path.
JMAP has an extension for this -- RFC 9007's MDN/send -- and Stalwart does
not implement it, so ihasmail assembles the RFC 8098 multipart/report itself
and sends it the long way round: raw MIME uploaded as a blob, imported,
submitted. That is also why the receipt lands in Sent, which is where it
honestly belongs.
The plumbing is the easy half. A receipt tells whoever asked that the address
is live and when the message was read, to an address the sender chose, so the
refusals are the feature: nothing marked Auto-Submitted (RFC 3834, or two
servers answer each other forever), nothing carrying Precedence bulk/list/junk
or a List-Id, nothing already acknowledged, nothing that never arrived. A
receipt aimed anywhere other than the sender is offered, but says so first.
There is no "always send" setting, only ask or never.
Sending is recorded with RFC 3503's $mdnsent keyword on the original rather
than remembered locally, so a second look -- or another client entirely --
knows not to ask again. Non-ASCII parts go base64 rather than 8bit, so
nothing rests on 8BITMIME surviving every hop.
Verified against the mock end to end: the blob uploads, the receipt imports
and submits, and the original reads back marked. Not yet exercised against
the live server.
Both branches turn on where Stalwart advertises a capability, so they meet
in the same two files. The mock keeps `urn:stalwart:jmap` out of the
session-level capabilities and hands it out per-account, as a real server
does, while the submission capability it grew for scheduled send lives
per-account beside it; the client keeps both accessors, one asking whether a
capability is advertised anywhere and one reading the object itself.
Self-service credentials, the About page and Files all keyed off
`urn:stalwart:jmap`, and all three looked for it in the session-level
`capabilities`. Stalwart has never put it there. `Session::new` builds that
list from a fixed set the capability is not part of, in any 0.16.x from
0.16.0 to 0.16.19; it is handed out per-account instead, so it arrives in
`primaryAccounts` and in each account's `accountCapabilities`.
So every real 0.16 server read as pre-0.16. Password changes, 2FA and app
passwords fell back to `POST /api/account/auth`, which 0.16 removed, and
reported that the server offers no self-service credential management. About
named the wrong generation. Files ran the pre-0.16 path, omitting `nodeType`
and listing the tree through get.
Look in all three places, on both sides. Two nearby soft spots go with it: a
transport error while probing the registry no longer downgrades a server to
the legacy path -- which would have posted the current password to an
endpoint that is not there -- and a locale request that is merely refused no
longer discards a generation the capability had already settled.
The mock advertised the capability in the session, which is why no test ever
caught this; it now advertises it where the real server does, and validates
`using` by the urn rather than by the session, as Stalwart does. Put the old
lookup back and nine tests fail.
Stalwart still publishes no version number to clients -- VERSION_PUBLIC is a
fixed "1.0.0" -- so About continues to report the generation and edition,
which are now the right ones.
Scheduled send, which the README listed as needing server support that
Stalwart has had all along. The delay cannot be asked for directly --
RFC 8621 makes `sendAt` read-only and server-derived -- so it goes on the
envelope as an RFC 4865 `HOLDUNTIL` parameter, and the server reports back
the time it settled on.
Stalwart advertises this in the *account* capability, not the session-level
one (which is empty): `maxDelayedSend` of thirty days and `FUTURERELEASE`
among its `submissionExtensions`. The composer offers scheduling only when
both are there, and never offers a time the server would refuse.
A held message goes to a Scheduled folder rather than Sent, because
`onSuccessUpdateEmail` would otherwise file it as sent the moment the
submission is created, and it has not been sent. Nothing moves it out when
the hold expires, so the folder is reconciled on the way in: released
messages to Sent, cancelled ones back to Drafts. Cancelling uses a separate
`Email/set` rather than `onSuccessUpdateEmail`, whose key Stalwart reads as
an Email id and not, as the RFC says, a submission id.
The mock grows the whole lifecycle, and learns to resolve creation
references while it is there -- it had been quietly declining to create any
submission at all, since sending names its message as `#m`. Because
Stalwart's own `futureRelease` setting defaults to off and then drops the
hold in silence, `npm run dev:mock:no-future-release` reproduces that.
Verified end to end against the mock; not yet against the live server.
The README described the deployment as 0.15.5 in four places. It has run
0.16.19 since 2026-08-25, which matters here because ihasmail supports both
generations of Stalwart and they are less alike than the 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.
Both backends have now met a real server of their own generation. The
registry paths for credentials and Files had only ever met the mock, and are
now exercised against the live 0.16.19: app passwords created and revoked,
password changed, 2FA switched on and off with the browser session surviving
the swap to an app password, and Files through folder creation, upload,
rename, move and delete. That closes the gap, and it was worth closing - the
0.16 half was resting on a mock we wrote ourselves, which is the arrangement
that let four bugs through on the 0.15 side.
Each known-issues entry now says which generation it was proven against
rather than implying one level of assurance across both. The account locale
is confirmed working on 0.16; on 0.15 neither method it can use was
reachable, so it had always fallen back to the browser.
Also records what did the upgrade. stalwart-migrator is a companion project,
and the 0.15 to 0.16 move is genuinely treacherous by hand - the store is
migrated in place with no way back, and Stalwart's own converter drops
settings without reporting them - so a reader running 0.15.x has a real
reason to want the link.
**The login rate limiter could be sidestepped.** X-Forwarded-For is a list each
hop appends to, and nginx's $proxy_add_x_forwarded_for appends ours — so a
client sending "X-Forwarded-For: 1.2.3.4" arrives as "1.2.3.4, <their real
address>". Reading the leftmost entry, as we did, handed the caller a
rate-limit key they could change per request: unlimited password guessing
against a deployment that looks correctly configured. Read from the right
instead, skip hops that are themselves trusted proxies, and believe the header
only when the peer is one (loopback and the private ranges by default,
TRUSTED_PROXIES to be explicit).
**The upload cap was a suggestion.** It read content-length, which a chunked
request simply omits. Count the bytes through a stream, as the image proxy
already does.
**App password secrets were drawn with a modulo.** 256 is not a multiple of 33,
so the first 25 characters of the alphabet came up on 8 byte values and the
last 8 on only 7. Rejection sampling instead. The test weighs the whole tail of
the alphabet rather than single characters, because a 7/8 skew is invisible
per character against the noise — and it does fail when the bias is put back.
**Upstream headers were relayed wholesale.** Anything the mail server set —
cookies, auth challenges, CORS grants — landed on our origin, where it means
something else. Allowlist what is actually wanted.
MOCK_STALWART=0.15 (or npm run mock:legacy) switches the mock to the
generation before the registry. It is not a cut-down mock: it reproduces the
specific ways that generation differs, and every one of them is a thing the
server does not report as an error.
- urn:stalwart:jmap is not a capability it knows, and naming one it cannot
parse fails the whole request rather than the one call
- x: methods do not exist; credentials live at POST /api/account/auth
- FileNode/query masks out containers, so it returns files and never folders
- FileNode has no nodeType, and rights are only mayRead/mayWrite/mayShare
Both modes now also enforce the 2047-byte signature cap, and `using` is
validated in both — the gap that let the Identity capability bug in #12 ship.
This gives the legacy credential adapter its first automated coverage: it was
the least-tested code here, checked only by hand against the live server. The
new tests also pin the mock's own fidelity, so it cannot quietly drift back to
being 0.16-shaped in the places that matter.
Both entries had been sitting as pending QA. Everything they were waiting on
has now run against the live 0.15.5 server: oversized, non-ASCII and
inline-image signatures (with a test message reaching Gmail intact), and folder
creation, listing, upload, rename, move and delete in Files.
The Files entry now describes what actually differs before 0.16 — the query
that cannot see folders, the missing nodeType, the coarser rights — since all
three were found the hard way and none of them surfaces as an error.
The Dates & times entry still named x:Account/get as where the default locale
comes from, which this branch changed. It also never mentioned the top-bar
light/dark toggle or what About now reports about the server.
The REST credential path is no longer untested: password change, app passwords
and enabling and disabling 2FA were all exercised against the live server on a
real mailbox. The registry path is still mock-only.
Also correct the locale line. Both methods it can use are 0.16 ones, so on an
older server neither is reachable and the browser locale still wins — the fix
helps 0.16+ users, and the entry should not imply otherwise.
Settings › Security grows three working sections instead of a note telling
people to use Stalwart's own portal.
Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has
the x:AccountPassword singleton and x:AppPassword registry objects over JMAP,
while 0.15.x has the /api/account/auth REST endpoint. Which one answers the
probe is the only reliable way to tell them apart, and the result is cached
per session. The built-in `user` role already grants sysAccountPassword* and
sysAppPassword*, so no administrator setup is needed.
Two problems are worth calling out, because both would bite a user hard:
Stalwart validates the credentials already on the account when 2FA is turned
on and never checks the new secret, so an authenticator that was mistyped or
out of step would lock someone out of their mailbox at the next sign-in. We
verify a code against the new secret ourselves first (RFC 6238, tested against
the spec's vectors) and only then ask the server to store anything.
Every proxied call re-authenticates with the credential sealed into the
session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with
it — which we cannot produce between requests. Turning 2FA on would therefore
sign the user out of the browser they just turned it on in. App passwords
authenticate without a second factor, so the session is moved onto one minted
for this browser, and the session cookie is re-sealed with it. The order
matters: it is minted while the old credential still works, and revoked again
if enabling then fails.
Password changes re-seal this session too and drop the others, whose sealed
copies of the old password would fail on their next call.
The mock now enforces what a real server does — current password, password
policy, a TOTP code on every request once 2FA is on, app passwords exempt —
so the whole flow is exercised in tests rather than only by hand.
Right-clicking a sender, or any address in the message details, opens a menu
offering to add that person to the address book - plus edit them when they are
already known, write to them, or copy the address.
"Add to contacts" opens the contact editor prefilled rather than saving
silently, so the address book gets a real card that the user can complete,
not a bare email address. contactFromAddress splits the display name into
JSContact name components: "Ada Lovelace" into given and surname, "Lovelace,
Ada" unpicked, a single word as the given name, and a name that is really
just an address left off entirely.
Addresses in the details block were joined into one string, so they are now
rendered per address to be individually targetable.
ContactEditor previously ignored a prefilled name on an unsaved card - it read
name components only when the card had an id - so it now reads them either
way.