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.
Two cosmetics.
Picking a folder colour left the menu open, which every other action in
it does not. It closes now, the way the calendar's colour menu already
did.
The live-updates indicator was an 8px flat speck in --fg-faint, near
invisible in either theme, and it had two states where the code has
three. The push client only ever said connected or not, which cannot
tell "retrying with a backoff" from "stopped": it now reports
connecting, connected or disconnected, and the retry path says
connecting rather than going dark. pushConnected stays for the callers
that only want the boolean.
The dot is 12px and raised -- a white highlight over a solid colour with
a soft halo, so one bead reads on light and dark alike without a
per-theme variant. Green connected, amber reconnecting with a slow
pulse, red disconnected. The pulse respects prefers-reduced-motion, and
the indicator is labelled for a screen reader rather than hidden from
it, since it carries real information.
A tinted outline barely registered against the sidebar. The icon is now
filled with the colour, which is what makes it findable at a glance in a
list of a dozen folders.
Two details it needed. The fill has to come from CSS, because lucide
writes fill="none" as a presentation attribute on the svg and a rule
beats one. And the strokes are drawn in --bg rather than the colour: a
solid fill in one colour swallowed the detail inside icons that have any
-- Archive lost the lid and handle of its box and became an orange blob.
Knocked out against the background they read again, in either theme,
since --bg follows the theme rather than being pinned to one.
Checked by pixels and by eye in both themes: the coloured area of the
icon went from an outline to 55% of its box, and Archive, Newsletters
and Work are all still recognisably themselves.
Right-click a folder and pick one of the twelve colours the calendar
already uses, or clear it again. The colour tints the folder's icon; the
label keeps the sidebar's own contrast, which a dozen arbitrary colours
would not reliably give it.
Kept by mailbox id rather than by name, so a folder renamed or dragged
somewhere else keeps its colour. Stored in settings, which live in this
browser -- JMAP has nowhere on a Mailbox to put a colour, and every other
colour in the app, labels and event categories included, already works
this way. Worth knowing it does not follow you to another device.
The cascade needed care: .nav-item svg sets the colour on the icon
itself, so a colour inherited from a wrapper does nothing. Checking
getComputedStyle on the wrapper said the icon was purple while the pixels
stayed grey; the rule now targets the svg, and the check now reads the
pixels.
Reparenting a folder meant the Folders settings page, or nothing at all.
The tree already accepted messages dropped onto a folder, so folders now
travel the same way: drag one onto another to nest it, or onto the
Folders heading to bring it back to the top level.
The heading says "Drop here for the top level" while a folder is in
flight, because an unlabelled strip of heading is not a discoverable
target. The row being dragged fades, the row under the pointer is
outlined, and only rows that would accept the drop light up.
Four drops are refused: a folder onto itself, into its own subtree,
onto the parent it already has, and any folder the server gave a role,
which is not draggable in the first place. The subtree case is the one
that matters -- it would orphan the branch -- and it checks the whole
subtree rather than the immediate children.
Whether a drop is legal has to be known during dragover, when
dataTransfer.getData is blocked, so the tree remembers what is being
dragged rather than asking the drag.
The move goes through updateMailbox, so the filter rules pointing at the
folder follow it, and the target folder is expanded afterwards so the
folder can be seen where it landed.
Deleting a folder removed every rule that filed into it, along with
whatever else those rules did. A rule that filed into Work, marked read
and stopped processing lost the marking and the stopping too, and
deleting a folder says nothing about whether those were still wanted.
Only the fileinto action goes now. A rule left with nothing to do is
still removed, because it has nothing to do; a rule filing into two
folders keeps the one that still exists. The toast says which happened.
Verified against the running app with two rules aimed at the same
folder, one filing only and one filing and marking read: the first was
removed, the second kept its markread, and the script stored on the
server agrees.
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.
A rule files mail into a folder by path, because that is what Sieve
needs. Rename the folder and the path becomes a lie: the rule keeps
matching and stops filing, and nothing anywhere says so. Delete the
folder and the rule is aimed at nothing at all.
Renaming or moving a folder now rewrites the rules that file into it,
and deleting one takes its rules with it. Both are reported in a toast,
because rules live on the server and are otherwise invisible from the
folder list.
The reconciliation runs off one hook. Before a mailbox is changed the
folder and everything beneath it are noted with the paths they have then
-- renaming a parent rewrites the path of every child, and rules naming
those children are just as stale. Afterwards, whatever still exists is
retargeted and whatever has gone takes its rules with it.
Rules record the folder twice, as a mailboxId and as the path. The id is
the reliable half and is preferred; the path is the fallback for rules
written before the id was recorded, or by hand in the Scripts tab, and a
rule matched that way has its id filled in on the way past. Only the
script the rule editor manages is touched; a hand-written one is left
alone.
Awaited rather than fired and forgotten, so a folder operation is not
reported complete while the rules still disagree with it.
The light inbox screenshot in the README was not light, and had not been
since it was first taken -- the pair showed the dark theme twice.
The app was never at fault: update() calls applyTheme() synchronously
and the CSS flips --bg to #f6f8fa as it should. The capture was.
Swapping the theme under setDeviceMetricsOverride produces a mixed
frame, the panes that re-rendered in the new theme and the rest of the
chrome still in the old one, while the DOM and computed styles insist
the whole page is light. Clicking the app's own toggle, setting the
attribute, pinning it against applyTheme with a MutationObserver,
installing that pin before the document loads, nudging the viewport and
forcing a full reflow all left the frame mixed.
Chrome launched at --window-size, with the emulation layer never
touched, renders it correctly. That is docs/screenshots-light.mjs, and
inbox-light.jpg is now genuinely light.
assertTheme() stays: without it the script wrote a dark screenshot under
a light caption and reported success, which is how this survived
unnoticed. The header says which shots are taken elsewhere and why.
The ones in the README were captured on 23 August, before the 2.0 QA
work: no drag handles on the filter rules, none of the calendar or
Sieve fixes, and the read-receipt line in the composer missing.
Same sizes as before, 1420x703 and 500x703 for the phone, so the table
lays out unchanged. The filters shot now carries four rules that say
something -- list-id, sender, subject, a wildcard match, each filing
somewhere different -- instead of four blank ones, and the calendar is
in the month view its caption has always claimed. A contact is open in
the contacts shot rather than an empty "select a contact".
The script that took them is committed alongside, so the next person
does not have to work out how to drive the mock: headless Chrome over
CDP, which is also how the viewport comes out at exactly the size the
old images used.
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.
Twenty-five rules and two buttons that move one place at a time meant a
rule pushed to the wrong end cost ten clicks to bring back. It can now
be dragged.
A grip on the left of each card arms the drag, so the switch, the name
and the buttons still take a plain click, and the up and down buttons
stay for the keyboard. The card being dragged fades; the one under the
pointer draws a line on the edge the rule would land on, top half or
bottom.
The guard against dropping a rule onto itself reads a ref rather than
state: dragstart and the first dragover can arrive in the same frame,
and a stale read there drew a drop line on the card being dragged. Found
by driving the real thing in a browser, and covered by a test that fires
the two events back to back.
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
Rules alone were still wrong, in the other direction. A live 0.16.19 was
asked to expand a real weekly series: the occurrences come back carrying
no rule at all — only the master has one — and Stalwart spells that
master's rule "recurrenceRule", singular, not the RFC 8984 array ihasmail
looks for. So a genuine occurrence would have read as a one-off, and the
delete dialog would have offered to delete "this event" while deleting
the series.
What an occurrence does carry is a recurrenceId, which a one-off never
has. Master by its rule under either name, occurrence by its
recurrenceId. The tests carry the shapes the live server returned.
A probe against the live 0.16.19 says a one-off event comes back from an
expanded query as id "eaaaaai" with baseEventId "i" — an instance id of
its own, and a base that is a different event. The clause that treated a
differing base as an occurrence of a series would therefore have gone on
calling every event recurring, which was the bug.
So recurrence rules alone decide it. What that gives up is an expanded
instance that arrives without its rules attached; whether Stalwart does
that is still to be checked against a real series.
Picking "Other header…" in the filter dialog took the comparator away.
The condition row has three columns — field, comparator, value — and the
box for the header name was rendered into the comparator's, so the
comparator disappeared along with any way to change it. Whatever it had
been when you switched, contains, was what the rule got: matching a
header exactly, or on a regex, could not be expressed at all.
The header name now has a column of its own and the comparator keeps
its, on a row that widens to hold both.
Fixes#23
A one-time event opened for editing said "this is a recurring event —
changes apply to the whole series", and deleting one offered to delete
all occurrences of an event that has exactly one.
Three places asked whether an event had a baseEventId and took that for
recurrence. It isn't: the calendar loads its range with expandRecurrences,
and Stalwart puts a baseEventId on everything it returns that way, a
one-off pointing at itself included. The mock never sets the field at
all, which is why this only showed up against a real server.
They now share isRecurring(), which asks about recurrence rules, and
treats a base that is some other event as an occurrence of a series too
— so an expanded instance that travels without its rules is still
described honestly on the way to being deleted.
Fixes#25
Renaming or editing a Sieve rule moved it to the bottom of the list, and
in Sieve the order is the order the rules run in, so mail started being
filed by a different rule than before. Putting it back took a click per
place moved.
Two things did it. saveAndApply always appended the rule it was given —
right for a rule created from a message, wrong for one being edited. And
the "Also apply to existing messages" tick defaulted to on wherever it
was offered, so every edit in Settings went down that path, including a
plain rename.
The rule now keeps its seat: a shared upsertRule replaces by id in place
and only appends what is genuinely new. The tick defaults to on only in
"Filter messages like this…", where applying it is the point, and the
toast no longer calls an edited rule "created".
Fixes#24
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.