Compare commits

...
Author SHA1 Message Date
jcoffey-dev 5b353d1e54 Merge branch 'ci/safe-directory' into 'main'
Let root jobs use a checkout the node job chowned

See merge request coffey-labs/ihasmail!6
2026-09-20 23:31:59 -07:00
jcoffey-dev bb133b88e1 Let root jobs use a checkout the node job chowned
The build directory is reused between jobs, and the node job chowns it to
the unprivileged node user for its tests. A later job running git as root
then finds the checkout owned by someone else and git refuses with
"detected dubious ownership" (exit 128). Which cached directory a job lands
on decides whether it happens, so it is intermittent: the first weekly
release dry run passed and the second failed.

The version job in the tag pipeline runs git as root too, so the same
refusal would have stopped a release from ever publishing its image. Both
jobs now mark the project directory safe before touching git.
2026-09-20 23:29:19 -07:00
jcoffey-dev 5fc63068b0 Merge branch 'ci/weekly-release-tagcheck' into 'main'
Look tags up by exact ref in the weekly release

See merge request coffey-labs/ihasmail!5
2026-09-20 23:23:42 -07:00
jcoffey-dev 874d25a40c Look tags up by exact ref in the weekly release
The dry run reported tag v2026.9.20-gc927c69 as existing when it did not.
On the git in the job image (2.39), rev-parse --verify refs/tags/<name>
falls back to reading a name ending in -g<hex> as git-describe output, and
resolves it to that commit. Every commit not merged through a pull request
gets a -g<hex> version, so every such week would have been skipped as
already released -- silently, since skipping is a normal outcome.

show-ref --verify matches an exact ref and nothing else. Both tag checks
use it now. release.yml has the same code; it only worked because GitHub's
runners carry a newer git that does not fall back.
2026-09-20 23:20:59 -07:00
jcoffey-dev c927c69fe2 Merge branch 'ci/weekly-release' into 'main'
Cut the weekly release on GitLab

See merge request coffey-labs/ihasmail!4
2026-09-20 23:16:36 -07:00
jcoffey-dev 0e63c5d9c9 Cut the weekly release on GitLab
release.yml stopped running with the GitHub account, and nothing replaced
it: no tag has been cut since, so the publish job had nothing to build.

This ports its decision unchanged -- release only when main has commits
since the newest published release, and only if the tag does not already
exist -- to a job run by a Monday 09:17 UTC pipeline schedule. The schedule
lives on the project and sets RELEASE_WEEKLY=1; DRY_RUN=1 stops after the
decision.

The release, and so the tag, is created with a project access token rather
than the job token. That makes the tag an ordinary push, which starts the
tag pipeline and its publish job, replacing release.yml's direct call of
publish.yml. The checks are skipped in the release pipeline, as they were
on GitHub: main has already passed them.
2026-09-20 23:14:17 -07:00
jcoffey-dev b6f73624b1 Merge branch 'images/registry' into 'main'
Point image references at the new registry

See merge request coffey-labs/ihasmail!3
2026-09-20 22:55:24 -07:00
jcoffey-dev 2c6df11e4b Point image references at the new registry
ghcr.io went dark with the GitHub account, so every `docker pull` and
template that named it has been failing. The images are republished,
multi-arch as before, at registry.coffeylabs.org under the same names, and
pull anonymously -- nothing needs a login.

Where a link pointed at an issue list, it now goes to /-/work_items: in
GitLab 19 that is the public list, and /-/issues returns 404 to anyone not
signed in. Issues themselves did not come across from GitHub, so a link to
a specific old issue is replaced with a note saying where it was.
2026-09-20 22:49:00 -07:00
jcoffey-dev 171b399e01 Merge branch 'ci/image-version' into 'main'
Build published images with the version they report

See merge request coffey-labs/ihasmail!2
2026-09-20 22:27:14 -07:00
jcoffey-dev 540554c111 Build published images with the version they report
publish.yml passed the computed version into the image build, and the
first port of it to GitLab CI did not. A tag pushed with that port would
have shipped an image reporting itself unversioned (or, for ihasvpn, with a
stray leading "v" no earlier build had), and tagged it with the git tag
rather than the version string.

The version is now computed the way publish.yml computed it and passed as
the build arg, and the image is tagged with it, '+' turned into '-' where a
Docker tag needs that.
2026-09-20 22:19:17 -07:00
jcoffey-dev 7453484280 Merge branch 'ci/gitlab-pipeline' into 'main'
Run CI on the self-hosted GitLab

See merge request coffey-labs/ihasmail!1
2026-09-20 20:04:16 -07:00
jcoffey-dev 2441e47390 Give CI jobs IPv6 rather than a Node flag that did not help
The proxy test failed with ECONNREFUSED on 127.0.0.1 for a server bound
to ::1. That is not resolution order, so --dns-result-order was treating
the wrong cause and is removed: with no non-loopback IPv6 address on the
container, getaddrinfo's AI_ADDRCONFIG drops ::1 from the results
altogether and localhost can only ever come back IPv4.

The runner now puts jobs on a docker network created with --ipv6, which
is where the fix belongs. Verified by reproducing the failure on the old
network and watching it pass on the new one.
2026-09-20 20:00:31 -07:00
jcoffey-dev 6e23c14132 Make the CI job environment match what the tests assume
Three tests failed on the runner and pass locally, all because the job
container differs from a workstation rather than because anything
regressed: config.test.ts chmods a directory and expects the write to be
refused, which root ignores; imageproxy.test.ts binds to ::1 and asks for
localhost, which resolves to IPv4 first here; and version.test.ts shells
out to git, which the slim image does not ship.

So the job installs git, runs the suite as the image's unprivileged node
user, and asks Node for the address order the proxy test was written
against. No test changed.
2026-09-20 19:55:43 -07:00
jcoffey-dev 6bfd105ad2 Run CI on the self-hosted GitLab
GitHub Actions stopped being reachable when the account was suspended, so
this ports ci.yml and publish.yml to a .gitlab-ci.yml running on a group
runner on Web_Host. The Actions workflows stay in the tree: they are the
reference this was written from, and they work again unchanged if the
appeal succeeds.

Two differences worth knowing. Images are pinned by digest rather than the
workflows' SHA-pinned actions, because GitLab has no action allowlist to
back a tag with. And arm64 is built under QEMU instead of on a native
runner, which is slow enough that publish is tag-only.
2026-09-20 19:37:18 -07:00
jcoffey f627bfc123 The toolbar above an open message acts on that message (#414) (#417)
With conversation view off, marking a message unread from the list --
the hover button, the right-click menu -- marked that message. Opening
it and pressing Mark as unread in the toolbar above it marked every
message in its thread, and so did Move to, Report spam and Delete.

The setting already reaches all the way into the reading pane: the list
draws one row per message, and `visibleMessages` narrows the pane to the
one opened. The toolbar was half converted. Its labels were right --
Mark as unread against Mark as read, the star, the labels shown -- all
of those read `messages`, which is the narrowed set. Only `rowIds`, the
one thing actually handed to the action, still read `thread.emailIds`.
So the button said one message and did the whole conversation.

`rowIds` is now the same question `visibleMessages` answers for the
pane, asked of the same ids, with the same fallback: an id that names
nothing in the thread -- a link from somebody with conversation view on,
a stale `m` in the URL -- shows the conversation, so the toolbar takes
the conversation. Conversation view on is unchanged: nothing is singled
out, so the whole thread comes back as before.

No new strings.
2026-09-20 14:53:45 -07:00
jcoffey 01dc322aeb A reply to a self-addressed message follows its Reply-To (#415) (#416)
A website contact form mails the site's own address: From and To are
both info@thesite, and the person who filled the form in is in Reply-To.
Replying addressed the draft to info@thesite -- the site's own desk --
instead of to them.

The reply already knows two shapes. A message somebody sent me is
answered to its Reply-To, which is what that header is for. A message
*I* sent is answered to the people I wrote to, and deliberately not to
my own Reply-To, which is where answers to me belong and would send my
reply to myself. A contact form passes the test for the second: every
address in From is mine.

So it fell down the chain the second shape keeps for a message with
nobody obvious to answer -- To without me, then Cc, then, having run
out, every address on the message, which here was mine alone.

The Reply-To now goes in that chain, one step before the last: when no
recipient but me is left and the message names a Reply-To that is not
mine either, that address is who it is really from. Keeping it after the
Cc is what leaves a message I did send alone -- somebody I actually
wrote to still beats my own Reply-To, which is the case the existing
guard was built for and its test still holds.

No new strings.
2026-09-20 14:50:48 -07:00
jcoffey 23557a72a2 Quote images through the proxy, and unproxy them on the way out (#412) (#413)
Reading a message fetches its remote images through this server, so the
sender learns nothing about the reader. Quoting the same message into a
reply fetched them directly: same pixel, same reader, but the request
carried their IP and user agent -- exactly what the proxy withholds.

A quote now proxies them the way the message view does. That alone would
be wrong, because a proxied URL belongs to this deployment: sent
unchanged it would reach the recipient as images only this server can
serve, broken for them and a beacon back here. So buildEmailObject turns
them back into the addresses they came from, beside the pass that
restores images blocked under pr411 and the one that turns editor blob
URLs into cid: references.

Deployments with the proxy off are unaffected: the quote fetches
directly, as reading does there.

Three tests from pr411 asserted the address sat in src when images were
allowed, which was the old behaviour; they now ask whether the draft
fetches it at all, proxied or not.

No new strings.
2026-09-19 16:08:52 -07:00
jcoffey d329b33912 Quoting follows the message's own image decision (#410) (#411)
Replying sanitized the quoted body with allowRemote: true, so quoting
fetched every remote image in the message whatever the reader had
decided about it. A tracking pixel in the quote then reported the
message read, and the address live, to whoever was counting -- the thing
leaving the images blocked was meant to prevent. Edit as new and opening
a draft that quotes a message did the same.

The decision now lives in one place, remoteImagesAllowed(), asked with
the same inputs the reader's answer used: the image policy, the trusted
senders, whether the sender is a contact, and whether Show images was
pressed on that message. The last of those was component state, so it
moves to the mail store, where the composer can see it.

Blocked images already keep their address in data-ihm-remote, so nothing
is lost by not fetching: it goes back on the way out, and the sent quote
is what its sender wrote. The recipient's client decides for itself, as
it would with any other client's reply.

Before pr408 this needed a rich-text default to reach; the format offer
made it reachable from plain text, which is how it was found.

No new strings.
2026-09-19 15:48:13 -07:00
jcoffey 88f9e6c50a Switching format keeps the original quote, not a flattened copy (#409) (#409)
Switching a reply between plain text and rich text converted whatever
body the draft was showing. Going from plain text to rich, that meant
the quoted message came back as the "> " text quote run through a
converter -- the sender's formatting, images and links gone, even though
the original markup was sitting on the draft untouched.

Both forms of the quote are prepared when the reply opens, so keep them
on the draft and re-attach the right one when the format changes. Only
what the author typed above the quote is converted. Where the quote
can't be found any more -- edited by hand, or a draft that quotes
nothing -- the whole body is converted as before, which is what every
non-reply draft does.

No new strings.
2026-09-19 15:17:12 -07:00
jcoffey d992442b81 Offer the message's own format when replying (#407) (#408)
A reply opened in the format the settings ask for, whatever the message
being answered was written in, and the per-draft switch was buried in
the composer's ⋮ menu. Replying in plain text to a rich text message
throws away the formatting; replying in rich text to a plain-text one
overrides what the sender chose to write in.

When the two disagree the composer now says so above the editor -- "This
message is rich text", with a Switch button and a dismiss -- and the
draft still opens in the format the settings ask for. Switching converts
that draft only and leaves the setting alone; switching from the ⋮ menu
answers the offer too. Forwards get it as well, where the formatting
being passed on is somebody else's.

What counts as rich text is hasHtmlAlternative(), which reads the body
part's own type: `htmlBody` is derived (RFC 8621 4.1.4), so a plain-text
message has one too and its presence proves nothing.

The mock said otherwise -- it returned an empty `htmlBody` for a
plain-text message, where Stalwart 0.16.21 returns the text/plain part
in both lists. Both builders now answer as the server does, so the path
this feature depends on is exercised in development rather than only
against a real mailbox.

Two new strings, translated in all nine catalogs; the buttons reuse the
menu's existing "Switch to plain text" / "Switch to rich text". The
count falling back to English stays at 16 in every language.

Fixes #407
2026-09-19 14:43:38 -07:00
jcoffey 07b39eb9b6 Call the app by its name in every sentence that names it (#406)
APP_NAME renames an instance, but only the sign-in page, the title bar and
a few headings used it. Two dozen sentences wrote "ihasmail" into
themselves, so a renamed instance still told people to keep an ihasmail
tab open and offered to open mail links "in ihasmail".

Those sentences now take the name as {app}, which also lets a translator
put it where their language wants it. brand.ts grew useAppName() for
components and currentAppName() for the few places that build strings
outside React.

Left as they are: the Files folder "ihasmail", the Sieve script
"ihasmail" and ihasmail.org. Those name things a person can go and look
at, and renaming them would rename real data.

All nine catalogues keep their translations: the name inside each one
became the placeholder. Three of the strings had no translation before
and still fall back to English.

A test walks the sources and the catalogues so a new sentence can't
hard-code the name again.
2026-09-19 14:27:34 -07:00
jcoffey bc366ac047 Reorder folders by dragging, with special folders first (#402) (#405)
The folder tree ignored sortOrder: Inbox came first, then everything
A–Z, so Sent ended up among ordinary folders. The tree now lists Inbox,
then any order the user has chosen, then the other special folders
(Drafts, Sent, Archive, Junk, Trash), then the rest A–Z. Stalwart gives
every folder sortOrder 0 until someone orders it, so an existing
sidebar changes once, to that default.

Dropping a folder on the top or bottom quarter of a row puts it above or
below that row, with a line to show where it will land. Dropping on the
middle still nests it. Special folders can now be dragged, to be
reordered but never nested; on those, the whole row reorders by the
nearer half. The folder menu gains Move up and Move down, for the
keyboard and touch. Inbox stays first.

A reorder numbers the level 10 apart and writes only the folders whose
number changes, in one Mailbox/set. The order is saved on the server,
so it follows the account to every device and to other JMAP clients.

No new strings: Move up and Move down were already translated.

Fixes #402
2026-09-19 14:06:21 -07:00
jcoffey 05df758d0a Open the composer full screen, as a setting (#401) (#404)
Settings > General > Composing has a new switch, "Open the composer full
screen". With it on, every new composer, whether a new message, reply,
forward or reopened draft, starts maximized. Restore still shrinks it to
a window. A draft put back after an undone or failed send keeps the size
it had. It's off by default, and on a phone, where the composer already
fills the screen, it changes nothing.

One new string, translated in all nine catalogs. The count falling back
to English stays at 16 in every language.

Fixes #401
2026-09-19 14:06:18 -07:00
jcoffeyandmbjboon-netizen 05d1645ab7 Update nl.ts (#403)
Signed-off-by: mbjboon-netizen <[email protected]>
Co-authored-by: mbjboon-netizen <[email protected]>
2026-09-19 13:42:51 -07:00
jcoffey 091782ae3a Drag calendar events to another day in the week grid (#400)
A timed event in the week view now moves sideways across the columns
as well as up and down, landing on the new day at the hour it was
dragged to. All-day chips above the grid drag between days the way
month chips do. Both drags count from the day the event was picked up
on, so a multi-day event grabbed on its last day moves by the distance
dragged, not by its length.
2026-09-18 21:19:58 -07:00
jcoffeyandJoe Esteves c118184975 Match Shift+letter shortcuts (Shift+I, Shift+U) (#399)
comboOf() let a shifted letter encode Shift in its case, so Shift+I
produced "I" and never matched the "shift+i" / "shift+u" bindings for
mark as read / unread. Shifted letters now yield "shift+<letter>";
symbols such as "#" and "!" still carry Shift in the character.

Fixes #398

Co-authored-by: Joe Esteves <[email protected]>
2026-09-18 08:22:13 -07:00
jcoffey 2740129c6a Keep only the app page as the app page (#396)
The service worker answers app routes from its kept page (#395), and it
kept whatever the mount's root returned at install and whatever HTML a
navigation returned. Where the root is not the app -- demo.ihasmail.com
puts its landing page there -- a returning visitor got the landing page on
every route.

The kept page is now only ever the app page, recognised by the asset list
the build writes into it: install fetches /mail instead of /, a
navigation's page is kept only if it is the app's, and a foreign page left
by the earlier worker is dropped when this one activates. Only the app's
own routes are answered from it; the root and any page in front of the app
go to the network. The reload for a new build primes the kept page from
/mail for the same reason.
2026-09-16 15:07:45 -07:00
jcoffey 82dc877fe1 Start at once on a device marked as your own (#395)
* Start at once on a device marked as your own

On a distant link, opening the app waited on four round trips before the
inbox showed: the app page, the session, the folder list, then the folder.

A trusted device now starts from what it kept:

- the service worker answers an app route from its kept page and fetches a
  fresh one behind it; the app checks the server's version at start, and a
  reload for a new build puts the new page in place first, so it is not
  answered with the old one. Assets of the page just replaced are kept one
  build longer for a tab still running it.
- the session's public details, so requests for mail go out before the
  server has confirmed the session; the answer replaces it, and a session
  that has ended lands on the sign-in form as before.
- the folder list and the first page of up to four recently read folders,
  list properties only, so the folders and the inbox paint before any reply
  and the folder query does not wait on the folder list. The "folder no
  longer exists" check still waits for the server's list.

All of it goes through the storage gate: nothing is written or read on a
device not marked as the reader's own, and signing out clears it.

* Show nothing kept before the session is confirmed

Starting from a kept session put the kept inbox on screen before the
server had said the session was still good; a session that had ended
showed mail and then the sign-in form. The spinner stays until the
server answers, as before.

The kept session is gone -- it existed only to start early. The kept
folder list and rows are still applied, from setAccount, which runs once
the session is confirmed: the inbox paints the moment that answer
arrives, and the folder query goes out then without waiting on the
folder list. An unreachable server lands on the sign-in form as before.
2026-09-16 13:47:59 -07:00
jcoffey 4c67460450 Fetch the rest of a new build in the background (#394)
The app page names only what it loads at start. The composer, settings,
viewers and the rest were fetched when first used, and after every deploy
that first use waited on the server -- and the worker's tidy-up dropped
them again at the next deploy anyway.

The build now writes the list of all its files into the page as an inert
JSON block. The service worker keeps everything listed and, once a page
names files it does not hold, fetches them three at a time; a load cut
short is resumed at the next navigation. Language catalogs are listed
apart and left to be cached when used, and nothing is fetched ahead when
the browser is set to save data.
2026-09-16 13:29:57 -07:00
jcoffey e158ebac5a Fold a push's follow-up requests together (#393)
A pushed mail change took three round trips: Email/changes beside a
Mailbox/get, then Email/get for what changed, then the list, the open
thread and a second Mailbox/get. Each page of changes now carries its own
Email/get calls by back-reference, and the one Mailbox/get goes out with
it, so a push settles in two. New mail fetched this way is not asked for
again by the notice.

A reply's sessionState that differs from the session is announced once
rather than on every reply, and session refreshes in flight are shared.
The mock's session state now matches the sessionState on its replies, as
Stalwart's does; tying it to the data counter made every reply trigger a
session refresh in development.
2026-09-16 13:22:26 -07:00
jcoffey 786976312f Open conversations in one request, and start them early (#392)
On a 250 ms link, opening a conversation took two round trips: Thread/get,
then the bodies. It now takes one. A known thread sends Thread/get and the
missing bodies in the same tick; an unknown one chains Email/get off
Thread/get with a back-reference, and falls back to fetching in parts when
the thread is longer than one Email/get may carry.

Conversations also start loading before the click: when the pointer rests
on a row, as soon as a press begins, and for the row below the open one.
The open waits for that load and does not repeat it.

Going back to one of the last twelve folders shows its previous list at
once, less messages that have left it, while the query runs.
2026-09-16 13:15:21 -07:00
jcoffey 5fe89d6e15 Merge pull request #391 from Coffey-Labs/feat/share-confirm
Ask before opening a shared item in a message
2026-09-16 12:27:33 -07:00
jcoffey-dev f79915aa89 Drop a wrong issue reference from a comment 2026-09-16 12:23:47 -07:00
jcoffey-dev 191c4e7e68 Ask before opening a shared item in a message
The share address takes a plain form POST, which any website can make,
and the app opened whatever arrived straight into a composer. It now
shows what was shared -- the title, the start of the text and link, and
the file names -- and opens a message only when the reader chooses to.
Discarding drops it.

Confirm dialogs now put a message that is not plain text in a div, since
the summary has blocks of its own.

Three new strings, translated in all nine catalogs.
2026-09-16 12:23:27 -07:00
jcoffey 4c1ceca8e9 Merge pull request #390 from Coffey-Labs/fix/accept-ranges
Advertise byte ranges on downloads, and record the live checks
2026-09-16 12:13:20 -07:00
jcoffey-dev 8a08c3d6db Advertise byte ranges on downloads, and record the live checks
Stalwart honors a single byte range on its download endpoint but sends
no Accept-Ranges, and Chrome's PDF viewer only reads a file in pieces
when the first response says it can. The proxy now says so itself.

Checked live on 0.16.22: ContactCard/changes reports creates, updates
and destroys exactly, which the contacts store's sync relies on, and a
range the server cannot serve gets the whole file with 200, never 416.
The mock now answers ranges the same way and sends no Accept-Ranges.
2026-09-16 12:10:20 -07:00
jcoffey ebf678be73 Merge pull request #389 from Coffey-Labs/fix/push-subscriptions
Stop duplicate push notifications and piling up subscriptions
2026-09-16 11:41:37 -07:00
jcoffey-dev 37eb145652 Merge main into fix/push-subscriptions
# Conflicts:
#	KNOWN-ISSUES.md
2026-09-16 11:39:35 -07:00
jcoffey 3d7602ce74 Merge pull request #388 from Coffey-Labs/fix/contact-photos
Save contact photos inline, and load cards so avatars show
2026-09-16 11:38:22 -07:00
jcoffey-dev 4054f82c37 Stop duplicate push notifications and piling up subscriptions
Browsers subscribed to Email changes, so every read or move on any
client arrived as a push the worker could only show as "New mail". They
now subscribe to EmailDelivery, which changes only on delivery; Stalwart
sends a delivery to a subscription with an emailPush filter as an
EmailPush alone. The payload now names id and threadId, which Stalwart
sends only when asked, so notifications carry their actions and open the
message. The worker stays quiet while a focused window is open, and the
page leaves notifications to the worker where push is on.

Every renewal registered a new subscription, on the belief that a
repeated deviceClientId replaces the old one. Stalwart keeps both and
allows fifteen per account, which filled up. A browser now extends its
subscription, clears its own duplicates, replaces them only when its
endpoint changed, and on overQuota makes room among other browsers'
subscriptions. The server names its subscriptions by installation and
removes what its previous process registered, and extends rather than
re-creates.

Checked live on 0.16.22; the mock now keeps duplicates, enforces the
limit and accepts an expiry update.

Fixes #375.
2026-09-16 11:36:07 -07:00
jcoffey-dev d38dee7eb9 Save contact photos inline, and load cards so avatars show
Stalwart refuses a blobId in a card's media ("blobIds in media is not
supported"), so adding or changing a photo always failed. The editor now
saves the photo as a data: URI, which Stalwart accepts and returns
unchanged, and leaves the card's other media as it was. Checked live on
0.16.22; the mock now refuses a blobId the same way.

Avatars in the mail list come from the address book's cards, and nothing
loaded those at sign-in, so a photo showed only after Contacts had been
opened. The cards now load in the background at start, the avatar uses
whatever cards are held, and a shared card's photo is fetched from the
account it belongs to.

Fixes #376.
2026-09-16 11:27:47 -07:00
jcoffey aa9bf1b9b2 Merge pull request #387 from Coffey-Labs/perf/client-memory
Let go of old message bodies and of exported files
2026-09-16 11:02:42 -07:00
jcoffey c63fd0dfe0 Merge pull request #386 from Coffey-Labs/perf/server-static-and-caches
Precompress the bundle, validate the shell, and pass byte ranges on
2026-09-16 11:02:37 -07:00
jcoffey-dev f123467897 Let go of old message bodies and of exported files
Every message opened kept its full copy for as long as the tab was open.
The store now holds bodies for the 40 messages most recently wanted; older
ones go back to the list properties and are fetched in full again if
opened. The open conversation is never released.

Contact, settings and calendar exports go through downloadFile, which
releases the object URL once the download has started; three of them
never released it.
2026-09-16 10:59:57 -07:00
jcoffey-dev 71d211a13f Precompress the bundle, validate the shell, and pass byte ranges on
The web build now writes a Brotli and a gzip copy of each compressible
file, and the static handler serves the best one the browser accepts.
The bundle was gzipped again for every request and Brotli was never
offered; the main chunk is 122 KB with Brotli against 144 KB gzipped.

index.html and every static file carry an ETag, and a matching
If-None-Match gets a 304. The shell and the worker are revalidated on
every load and were downloaded whole each time.

Attachment downloads pass a plain byte Range to Stalwart and relay a 206,
so a PDF viewer or a video element can read in pieces where the server
allows it. On the reader's own device a blob is cached as immutable,
since its id names its content.

The upstream session and account-info caches drop entries past their age
on a timer; they lost an entry only on sign-out or refusal, not when a
session expired. The mock answers byte ranges.
2026-09-16 10:54:14 -07:00
jcoffey 56bd48e891 Merge pull request #385 from mbjboon-netizen/mbjboon-netizen-patch-1
Mbjboon netizen patch 1
2026-09-16 10:50:14 -07:00
jcoffey da87925b9c Merge pull request #384 from Coffey-Labs/perf/contacts-calendar-changes
Sync contacts by what changed, and hold fewer calendar windows
2026-09-16 10:45:12 -07:00
jcoffey-dev 360420402d Sync contacts by what changed, and hold fewer calendar windows
A pushed contact change, and every edit or import made here, reloaded the
whole address book. The store now keeps the state its cards were read at
and asks ContactCard/changes what changed since, fetching only those cards,
split to maxObjectsInGet. A server that cannot say falls back to the full
load.

The calendar held every week or month the reader had visited, queried each
of them again on any event change, and walked them all on every render. It
now holds the four most recently shown; a change reloads those in place,
without emptying the view first, and a window dropped is loaded again when
it is next shown. Shared calendars' events are fetched from every account
at once, and instancesIn builds the added-shares set once.

The mock keeps a ContactCard change log, answers ContactCard/changes, and
announces a ContactCard/set, as Stalwart does.
2026-09-16 10:41:21 -07:00
mbjboon-netizen 8bd7904a21 Update nl.ts
Signed-off-by: mbjboon-netizen <[email protected]>
2026-09-16 19:07:54 +02:00
jcoffey 6090442058 Merge pull request #383 from Coffey-Labs/perf/lazy-store-init
Ask shared accounts together, and about files only when Files opens
2026-09-16 10:06:53 -07:00
jcoffey-dev 4c7b2ec370 Ask shared accounts together, and about files only when Files opens
At sign-in the files, contacts and calendar stores each asked every shared
account a question, one account after another: a request apiece before the
reader had opened any of those views.

Files now only works out at sign-in whether it is available. Which shared
accounts hold files is asked when the Files view or the file picker opens,
which the Files view already did on every visit. Shared address books and
calendars are asked for in one request, and the calendar store loads its
calendars, identities and shared calendars side by side.
2026-09-16 10:04:24 -07:00
jcoffey 9560ad06f4 Merge pull request #382 from Coffey-Labs/perf/lazy-chunks
Load the composer, previews, dialogs and other sidebars on demand
2026-09-16 09:50:14 -07:00
jcoffey-dev 6139031689 Load the composer, previews, dialogs and other sidebars on demand
The main chunk carried everything the mail view might open: the file
preview and its Markdown renderer, the composer and its editor, the contact
editor, the filter and share dialogs, and the calendar, contacts and files
sidebars. Each is now loaded when first shown. The composer is also
fetched when the browser is idle after startup, so the first Compose does
not wait on the network.

Import the notification helpers statically where they already were: the
dynamic imports beside those static ones split nothing.
2026-09-16 09:48:04 -07:00
jcoffey e3cd56314b Merge pull request #381 from Coffey-Labs/perf/message-list-rows
Render only the message rows that changed
2026-09-16 09:22:08 -07:00
jcoffey-dev c6dbcaef63 Render only the message rows that changed
The rows were memoized, but nothing they were given kept its identity: the
list built each row's thread messages afresh, passed inline handlers and the
whole selection, and the click and context-menu handlers changed with the
selection and the menu. Every visible row rendered on every store write.

Rows now select their own message and conversation from the store, take a
plain selected flag, and get handlers whose identity never changes. The
conversation summary is memoized.

Refreshes also keep the object for a message whose fetched properties did
not change, so a refresh that changed one message renders one row.
2026-09-16 09:10:41 -07:00
jcoffey 460760ba12 Merge pull request #380 from Coffey-Labs/fix/sw-cache
Keep the service worker's cache to the current build
2026-09-16 09:01:20 -07:00
jcoffey-dev a607450aaa Keep the service worker's cache to the current build
Cache a build asset only when it arrived: a 404 for a chunk asked for while
a deploy was changing over used to be kept as that chunk in that browser.

Refresh the offline copy of the app page after every successful page load,
and when it changes, drop the assets it no longer names along with any
failed response. The same tidy runs when this worker activates, which
clears what earlier workers left. Every deploy's chunks used to stay in
the browser for good.

The cache keeps its name: it also holds what the worker leaves for a tab
to collect.
2026-09-16 08:59:01 -07:00
jcoffey a9302075e7 Merge pull request #379 from Coffey-Labs/fix/security-followups
Close the smaller gaps from the security review
2026-09-16 08:50:31 -07:00
jcoffey-dev dfe885a921 Close the smaller gaps from the security review
Ask for the account password before minting an app password, and keep
sessions the proxy checks from writing the account's own registry objects,
so a session left open on someone else's machine cannot take a credential
away from it. The password is compared with what the session holds; Stalwart
is asked only when 2FA moved the session onto an app password.

Serve attachments and proxied images with no-store on a device that is not
the person's own. Give files from a winmail.dat only the types the server
would show inline. Strip direction controls from sender and attachment
names and from saved filenames.

On signing out, send what is inside its undo window, then close every
composer, so the next person to sign in does not find the last one's draft.

Group sessions by the account Stalwart names and its server, so "sign out
other sessions" also reaches a session opened as a bare or differently
cased username.
2026-09-16 08:47:23 -07:00
jcoffey 9691a7bbf5 Merge pull request #378 from Coffey-Labs/fix/push-refresh
Keep list refreshes within the server's limits and stop repeating them
2026-09-16 08:33:01 -07:00
jcoffey-dev e2b4cc18db Keep list refreshes within the server's limits and stop repeating them
Refresh the list in pages of at most maxObjectsInGet. A list scrolled past
500 rows used to send all of its ids to one Email/get, which the server
refuses whole, and the refresh failed without a word.

Fetch the other messages of listed threads in their own capped requests,
and only those not already held. They used to be back-referenced from
Thread/get with no bound.

Have loadThread fetch bodies only for messages not held in full. Every push
refetched the whole open thread's bodies, and the new attachment objects
made the reading pane redo work it had already done.

Build the list query from the folder names, roles and tree rather than the
mailbox map, which every reload replaces. Each reload used to build a new
query, which query() answered with another full refresh.
2026-09-16 08:25:57 -07:00
jcoffey 47a2477d9f Merge pull request #377 from Coffey-Labs/fix/server-hardening
Bound what a request can make the server hold
2026-09-16 07:58:47 -07:00
jcoffey-dev 98e105efd6 Bound what a request can make the server hold
Cap JSON bodies at 64 KB on every API route except JMAP and uploads, which
bound themselves. Sign-in used to read a body of any size before its rate
limits ran; the flood ceiling now also runs before the body is read.

For sessions whose JMAP requests are checked, lower the read cap from 16 MB
to 4 MB, allow four such reads per session at once, and turn requests away
with a 503 once 32 MB is held across everyone.

Count sign-in limits per /64 for IPv6, since one host holds a whole /64.

Bind the compose example to loopback, and run it read-only with no
capabilities and no-new-privileges. Keep .env.* out of git and the image
build context.
2026-09-16 07:54:20 -07:00
jcoffey b2e7db938c Merge commit from fork
Harden the email sanitizer's CSS handling
2026-09-16 07:40:38 -07:00
jcoffey-dev 55fcbf72f5 Harden the email sanitizer's CSS handling
Rewrite mail CSS in place instead of cutting pieces out, so a strip can no
longer join text into a closing </style>, and escape < last. Decode escaped
letters before checking, parse url() properly and drop CSS that cannot be
parsed, and disable @import and image-set() in every spelling. The body
element's style goes through the same path.

Give <area> links the same target, rel and click handling as <a>, strip
<style> blocks from HTML quoted into the composer, and contain the editor's
layout as .message-body already is.
2026-09-16 07:07:26 -07:00
jcoffey d0b13272f3 Merge pull request #374 from Coffey-Labs/refactor/lib-clusters
Group six more lib clusters, and split the mock server
2026-09-15 23:25:01 -07:00
jcoffey-dev 5b85c254e7 Split the mock server along the section markers it already had
server/src/mock/index.ts was 1,545 lines, the largest file in the repo.
It had carried `/* ---------- data ---------- */` style markers for a
while, so the seams were already drawn; this turns six of them into
files.

  mock/config.ts     51  env-derived constants, `account`, `state`
  mock/data.ts      410  fixtures and the builders that make them
  mock/engine.ts    442  the generic JMAP machinery -- get/set, filters,
                         patches, refs, limits, recurrence plumbing
  mock/handlers.ts  455  the `Method/name` dispatch table
  mock/events.ts     26  SSE fan-out and the Email/changes ring buffer
  mock/auth.ts       11  checkOtp
  mock/index.ts     195  HTTP routing, the session document, listen

TWO THINGS THAT COULD NOT JUST MOVE:

`counter` and `vacation` were module-level `let`s written from both the
fixture builders and the handlers. An ES module can export a `let` and
importers see it update, but they cannot assign to it, so both became
containers: `seq.counter` and `vacationBox.current`. Seven call sites.

`recordEmailChange`, `broadcast`, `sseClients` and `checkOtp` lived in
the HTTP section, but the handlers call them -- and index.ts imports the
handlers. Leaving them there is a cycle, so they became events.ts and
auth.ts rather than being dragged into data.ts, which is fixtures.

`account` is still exported from index.ts, because account.test.ts and
login-guard.test.ts reach for `mock.account` and `mock.server`.

Verified by running it, not only by compiling it: `npm run mock` boots
and listens, `/.well-known/jmap` returns a session, and a POST to
`/jmap/` answers Mailbox/get with the nine seeded folders and
Email/query with the seeded messages.
2026-09-15 23:22:34 -07:00
jcoffey-dev bd6a605d61 Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
2026-09-15 23:17:50 -07:00
jcoffey 5cc31037c1 Merge pull request #373 from Coffey-Labs/refactor/split-mail-store
Split the extractable parts out of the mail store
2026-09-15 22:54:03 -07:00
jcoffey e42c81ab09 Merge pull request #372 from Coffey-Labs/refactor/lib-domain-folders
Group the admin and calendar modules, and stop calling screenshots docs
2026-09-15 22:53:34 -07:00
jcoffey-dev 4517d154a2 Split the extractable parts out of the mail store
store/mail.ts was 1,463 lines. It is now a directory, so `@/store/mail`
resolves to index.ts and none of the 36 modules importing `useMail`
changes a line:

  mail/props.ts      72   MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS
  mail/types.ts     125   ListQuery, ListState, MailState, DEFAULT_SORT
  mail/mailboxes.ts  28   mailboxIcon, ROLE_ORDER
  mail/index.ts   1,266   the store, and everything bound to it

Everything exported before is still exported from index.ts, so this is
file layout and nothing else. No behavior change, no call-site change.

WHAT THIS DOES NOT DO, and why. index.ts is still 1,266 lines because
947 of them are one `create<MailState>((set, get) => ({ ... }))`. Cutting
that up means Zustand slices -- splitting the state object itself and
recombining it -- which is a change to how the store is built rather than
to where its text lives, in the part of the app that every screen leans
on. That deserves its own PR and its own argument, not a quiet ride along
with a file move.

Three things had to stay behind and are worth knowing about, because the
obvious boundary is wrong in each case:

  - `listKey` sits among the type declarations but is a function the
    store calls, not a type.
  - `ensureFolderPath`, `folderRefs` and `followFolders` read like folder
    helpers and look like they belong beside mailboxIcon, but they close
    over `useMail`. Moving them makes mailboxes.ts import index.ts, which
    imports mailboxes.ts.
  - the sieve import inside index.ts is `await import(...)`, not a static
    one, so rewriting import paths by their `from` clause misses it.
2026-09-15 22:49:14 -07:00
jcoffey-dev f7712b1c1e Group the admin and calendar modules, and stop calling screenshots docs
web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.

  lib/admin/     adminAccess, adminDashboard, adminDirectory, adminDomains,
                 adminGroups, adminLists, adminRoles, adminTenants
  lib/calendar/  appointment, availabilityWindow, eventDrag, ics, recurrence

Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.

recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.

docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.

No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
2026-09-15 22:44:53 -07:00
jcoffey 0bde2df69d Merge pull request #371 from Coffey-Labs/ci/pin-actions-to-shas
Pin every action to a commit SHA
2026-09-15 22:23:07 -07:00
jcoffey df06b8ea04 Merge pull request #370 from Coffey-Labs/docs/pull-request-template
Add the pull request template CONTRIBUTING.md already refers to
2026-09-15 22:23:03 -07:00
jcoffey-dev 441fb07cc9 Pin every action to a commit SHA
A tag is a mutable pointer. `actions/checkout@v7` is whatever the
publisher last moved v7 to, so using one is not trusting the version that
was reviewed -- it is trusting every future version, including whatever
is pushed by whoever compromises the publisher's account. That is the
shape of the tj-actions/changed-files compromise: no repository changed a
line, the tags moved underneath them, and the action began dumping runner
memory to the logs.

Each `uses:` now carries the full 40-character SHA with its release in a
trailing comment. Read the comment for the version; the SHA is what runs.
Dependabot already covers github-actions weekly and updates both halves
together, so keeping current costs nothing.

The dataaxiom cleanup action was already pinned -- it is handed
`packages: write` and deletes things, so it was worth doing early -- and
only picks up the trailing-version convention here. Its comment loses the
"rather than a moving major tag" framing, which is no longer what makes
it different from its neighbors now that they are all pinned too.

The two `uses: ./.github/workflows/...` entries are local paths, not
actions: they always resolve within the commit already running and there
is no SHA to pin.
2026-09-15 22:12:25 -07:00
jcoffey-dev f3ee4ff65d Document the fork CI approval gate in CONTRIBUTING.md
Belongs with the commit before it and was left out of it by mistake.

The repository's fork-pr-contributor-approval policy is now
all_external_contributors rather than GitHub's first_time_contributors
default, so every run on an outside contributor's branch waits to be
started by hand instead of only their first one. A contributor who does
not know that reads a build check that never appears as an orphaned run
-- which this repository has had, during the 2026-08-26 Actions outage --
and pushes again to shake it loose. Neither that nor reopening the PR
starts it, so say so where the other main protection notes are.
2026-09-15 22:11:47 -07:00
jcoffey-dev 1ec9579db2 Add the pull request template CONTRIBUTING.md already refers to
Step 7 of "Submitting Pull Requests" tells contributors to open the PR
"filling out the PR template", and there has never been one. The four
things it names -- summary, related issues, screenshots for UI changes,
manual testing -- are the four sections here, plus translations, which
step 8 asks for separately and which is the easiest of the five to
forget: a missing catalog key renders its English source rather than
failing, so nothing in CI or on screen says it was skipped.

Also documents the CI approval gate on fork PRs, now that every outside
contributor's run waits to be started by hand rather than only a
first-time contributor's. Without a note, a contributor whose build
check never appears reads it as an orphaned run and pushes again to
shake it loose, which does nothing.
2026-09-15 22:06:52 -07:00
mbjboon-netizen 4cb1945eb5 Update nl.ts
Signed-off-by: mbjboon-netizen <[email protected]>
2026-09-16 04:32:50 +02:00
jcoffey 3bfe9396b4 Merge pull request #369 from Coffey-Labs/chore/us-spelling-license
Use American English spelling
2026-09-15 11:49:23 -07:00
jcoffey-dev d1731efdb9 Use American English spelling throughout 2026-09-15 11:45:58 -07:00
jcoffey-dev 6ba89696ee Spell license the US way 2026-09-15 11:35:33 -07:00
jcoffey a7d36e1962 Merge pull request #368 from Coffey-Labs/docs/shorter-readme
Shorter README: an introduction and links
2026-09-15 10:54:17 -07:00
jcoffey-dev 8ce8f0590a Cut the README down to an introduction and links
Running ihasmail is covered on docs.ihasmail.org, now including the
published image, the admin URL and BASE_PATH. Architecture, the mock's
switches and version numbers move to CONTRIBUTING.md, and 0.16.21's
client-visible changes to KNOWN-ISSUES.md. The Gmail comparisons go.
2026-09-15 10:51:55 -07:00
jcoffey 8639e9885b Merge pull request #366 from Coffey-Labs/feat/detect-stalwart-admin-url
Find Stalwart's administration instead of asking for it
2026-09-15 10:07:27 -07:00
jcoffey d3fef4acb7 Merge pull request #365 from Coffey-Labs/docs/schema-route-confirmed
Record the schema route working on production
2026-09-15 10:07:10 -07:00
jcoffey 66892c794d Merge pull request #367 from Coffey-Labs/docs/scrub-server-hostnames
Take the production server's domains out of KNOWN-ISSUES
2026-09-15 10:07:01 -07:00
jcoffey-dev bb27501d70 Take the production server's domains out of KNOWN-ISSUES
The notes on today's live runs named the throwaway addresses and domain
they used, which named the production mail server's domains. What was
tried and what it answered stays; where it was tried does not need to be
public.
2026-09-15 10:04:44 -07:00
jcoffey-dev 2e2724c55c Find Stalwart's administration instead of asking for it
The dashboard's link to Stalwart's own administration needed
STALWART_ADMIN_URL, which an operator had to know to set. Everything it
holds can be read from the server:

- the origin is the host Stalwart advertises in its own session URLs, the
  one people reach it at even when ihasmail talks to it on a private
  address;
- the prefix is where its web interface application is installed. The
  production server's x:Application reads "Stalwart Web Interface",
  enabled, urlPrefix {"/admin", "/account"}, and /admin is also what
  Stalwart writes at first boot.

So, for an account that administers, the account info fetch now asks the
account's own server for its applications and links to origin + /admin/.
An installation whose web interface is disabled or moved gets no link; an
administrator who may not read applications gets Stalwart's default
/admin. It is cached with the rest of the account info.

STALWART_ADMIN_URL and a servers file entry's adminUrl still win, for an
administration that lives somewhere else. A routed domain without one now
takes what its own server said, never the default server's.
2026-09-15 10:01:18 -07:00
jcoffey-dev 74982068b2 Record the schema route working on production
After the deploy, /api/admin/permissions answered with all 661 permissions
Stalwart 0.16.22 publishes, the Roles picker drew them, and the bootstrap
roles' counts read as expected. KNOWN-ISSUES said it had not been tried.
2026-09-15 09:55:59 -07:00
jcoffey 53d9cd37fb Merge pull request #364 from Coffey-Labs/feat/admin-tenants
Add Tenants to Administration, and let an account be put in one
2026-09-15 09:51:05 -07:00
jcoffey 5f28393039 Merge pull request #363 from Coffey-Labs/feat/admin-roles
Add Roles to Administration, with Stalwart's permissions in every language
2026-09-15 09:50:55 -07:00
jcoffey 37a0e28871 Merge pull request #362 from Coffey-Labs/feat/admin-lists
Add Mailing lists to Administration
2026-09-15 09:50:42 -07:00
jcoffey 5a7a8019e9 Merge pull request #361 from Coffey-Labs/feat/admin-groups
Add Groups to Administration
2026-09-15 09:50:28 -07:00
jcoffey d17c9a6e1f Merge pull request #360 from Coffey-Labs/feat/admin-dashboard
Open Administration on a dashboard of what the role can read
2026-09-15 09:48:57 -07:00
jcoffey-dev 5f70e5e8d1 Say tenants are Enterprise only where the installation asks, as the demo will
On a server that is not Enterprise the Tenants page is still only the
notice. On Enterprise the notice is gone -- a real installation that has
tenants has the licence -- unless SHOW_ENTERPRISE_NOTICES=1 asks for it above
the list. The public demo will set it: it reports Enterprise so tenants can be
shown, and should not suggest they come without the licence. The setting
reaches the browser as session.ihasmail.server.enterpriseNotices.
2026-09-15 09:43:04 -07:00
jcoffey-dev 40df0f658b Follow what the live server does with tenants and domains
A run on the production server with throwaway tenants, a role, lists and a
domain, all removed, found three things the source reading had not:

- Something in a tenant has to be on a domain in that tenant (a list in a
  tenant on an unassigned domain is invalidForeignKey), while something in
  no tenant may be on a tenant's domain. The account panel's tenant choice
  offered every tenant; it offers only the domain's now, and a new account
  starts in the tenant of the domain it is made on. The domain list reads
  memberTenantId for it.
- A domain created in a tenant puts its DKIM keys there too, and they keep
  the tenant from being deleted. They are counted with the rest, so Delete
  is not offered while any remain.
- Stalwart lets a domain leave a tenant while the tenant still has accounts
  on it, stranding them. The panel asks first and refuses while any are
  there.

The refusal to delete a tenant that holds anything was confirmed, as were
tenant create, quota pointers, logo and rename. The mock follows the domain
rule, filters DKIM keys by tenant, and KNOWN-ISSUES records the run.

The non-Enterprise notice is now just "Tenants are a Stalwart Enterprise
feature." Two sentences were reworded and one plural added, in all nine
catalogues, and the old sentences are gone.
2026-09-15 09:37:00 -07:00
jcoffey-dev ce5eb04c2d Show only the Enterprise notice on Tenants when the server is not Enterprise
On a server that does not report Enterprise -- or reports no edition --
tenants hold nobody to anything beyond an ordinary user's permissions, so
the page is the notice alone: no New tenant, no search, no list, and no
tenant query is made. The mock's edition is MOCK_EDITION now (default oss,
as before), so MOCK_EDITION=enterprise brings the section back to work on.
2026-09-15 09:31:10 -07:00
jcoffey-dev fd104a1f34 Add Tenants to Administration, and let an account be put in one
A tenant is a separate organisation on one server: its own people,
domains and limits, and an administrator who manages only what is in it.
It gets a section under Access, gated by sysTenantQuery and sysTenantGet,
with a notice on a server that does not report Enterprise, where anyone
inside a tenant is held to an ordinary user's permissions.

The panel edits the tenant's name, logo, role and limits. The logo is an
https address, drawn through the image proxy the strict image policy
requires, or an image data URL. Limits change one quotas/<name> pointer
each, so the four ihasmail does not offer keep their values, and an empty
field is no limit. The role is the most anyone inside can be allowed.

Stalwart keeps no list on a tenant -- each account, group, domain, list and
role names its own -- so what a tenant holds is counted with memberTenantId
queries and shown against its limits. Domains are added and taken out from
the tenant's panel, one memberTenantId change each; only a domain in no
tenant can be added, and its accounts stay where they are. Delete is offered
once every count reads zero.

A tenant does nothing until someone administers it, so the account panel
gains a Tenant choice for an administrator who can read tenants: an
Administrator inside a tenant administers that tenant. Nobody moves their
own account.

The mock has a tenant holding a domain and an administrator, a spare domain
to assign, memberTenantId filters on every query, and Stalwart's rule that
only an administrator outside every tenant may move things into one. A test
of taking a domain back out found that the mock's pointer handling dropped a
top-level null instead of storing it, so nothing had ever been cleared that
way; it stores null now, as the server reads it back.

Nothing about tenants has been written on a live server: production has
none. KNOWN-ISSUES says what was read from source.

Thirty-nine new strings and one plural, in all nine catalogues.
2026-09-15 09:28:39 -07:00
jcoffey-dev 7e46ddeb7d Record the roles run on the live server, and drop the mock's made-up permission
A throwaway role on the production server confirmed the create shape,
one-pointer changes to permissions, bases and name together, the grant
refusal, and the in-use refusal when another role builds on it. It also
showed that a permission name Stalwart does not know fails the whole
update -- which is how jmapEmailSet, carried by the mock since Accounts
was built, turned out not to exist. The mock uses jmapEmailUpdate and now
refuses unknown names against the 0.16.22 snapshot.

Some permissions an administrator holds are never listed by /api/account
(sysLogCreate was granted without complaint), so the picker locks their
Allow; KNOWN-ISSUES says so.
2026-09-15 09:16:49 -07:00
jcoffey-dev a00d07b430 Add Roles to Administration, with Stalwart's permissions in every language
A role is a named set of permissions given to accounts, groups and
tenants. It gets its own section under a new Access heading: every role
listed with the permissions it grants once its bases are followed, and a
panel to create, edit and delete one.

A role builds on others and has everything they grant; a denial anywhere in
the tree wins, which is how Stalwart resolves it (permissions.rs unions
enabled and disabled across the tree, then subtracts). The picker is
Stalwart's own list of permissions, under its headings, searchable and
filterable to what is granted or set here. Each permission is not set,
allowed or denied, and one that is inherited says which role it comes from.
Only permissions the viewer holds can be allowed, because Stalwart refuses
the rest, and a role carrying anything the viewer lacks opens read-only with
no delete, because Stalwart checks a grant but not a delete. Saving sends a
pointer for each permission and base role that changed.

The roles Stalwart hands out by default, read from x:Authentication, say so
before they are changed and cannot be deleted here; a role still in use is
kept by the server, and the refusal names what uses it.

The permission list is Stalwart's schema. A new route, GET
/api/admin/permissions, fetches /api/schema as the signed-in account and
returns only names and labels, behind the same two gates as the registry
methods and held in memory for an hour. Its labels are English only, so
every one of the 661 has a translation in each of the eight other
languages, in its own file keyed by permission name and loaded only when
Roles opens. A permission a later Stalwart adds shows its English label. A
test holds every language to the 0.16.22 snapshot: nothing missing, nothing
stale.

The mock answers x:Role/set with the grant check, loops and in-use
refusals, reads the defaults from x:Authentication, and serves the schema
gzipped as the real one is.

Fifty-two new strings and two plurals in all nine catalogues, and 661
permission labels with 59 headings in each of the eight translations.
2026-09-15 09:13:05 -07:00
jcoffey-dev 627422d794 Add Mailing lists to Administration
A mailing list is an address that passes mail on to everyone on it. To
Stalwart it is its own object, x:MailingList, behind sysMailingList*, so
it gets its own section under Directory after Groups: search, fifty to a
page with each list's recipient count, and a panel to create, edit and
delete one.

Recipients are a property of the list, so unlike a group's members they
save with the rest of the panel. What Save sends for them is only what was
added and removed, one recipients/<address> pointer each -- the patch the
live server accepted -- so a recipient added elsewhere while the panel was
open is not taken out. They can be pasted several at a time, from a
spreadsheet column, a comma-separated line or Name <address>; anything with
an @ that is not an address stays in the box with a note. Past a dozen, a
filter narrows them.

That is all a list is in Stalwart -- no owners, moderation or posting
rules -- so that is all the panel offers.

The mock answers x:MailingList with two lists, the recipient set's live
shape, and the refusals a wrong address, a clash with an account and a
missing permission get.

Twenty-five new strings and one plural, in all nine catalogues.
2026-09-15 08:47:04 -07:00
jcoffey-dev 79afc334ab Record the groups run on the live server, and match its refusal shape
A throwaway group on the production server confirmed what the code was
built on: a Group account with Default roles, membership as a pointer on
the member, the members query, and a delete refused while a member still
names the group. Its objectId is an {object, id} pair rather than a bare
id; the mock answers the same way now.
2026-09-15 08:37:39 -07:00
jcoffey-dev e2a531b615 Add Groups to Administration
A group is a shared address and mailbox and the people who share it. To
Stalwart it is an x:Account of type Group, behind the same sysAccount*
permissions as a person, so it sits under Directory beside Accounts:
search, a page of fifty with each group's member count, and a panel to
create, edit and delete one.

Membership lives on the member, not the group. Members are the users whose
memberGroupIds name it, and adding or removing one is a single
memberGroupIds/<group> pointer on that user's account -- true or null --
which leaves their other groups alone. Changes apply straight away rather
than riding on Save, so the list is always what the server has. Nobody can
add or remove themselves, the same line the account panel draws at one's
own role.

A group's role is Default or Custom, not a person's User or Admin, and it
is what the group may do: in 0.16 a user's permissions come from their own
roles only, and a group gives its members what is shared with it. Only
roles the viewer could grant are offered.

Delete takes the members out first and then deletes the group, the order
a domain's keys go before the domain, because the registry keeps anything
another object names. A role that cannot change the members' accounts is
not offered a delete it could only half finish.

The mock's groups had a person's roles, accepted a memberGroupIds filter
without applying it, and answered a linked delete with the wrong shape;
all three follow the source now, and it refuses nested groups and
memberships of things that are not groups.

Nothing about groups has been run against a live server yet: production
has none, and every operation is a write. KNOWN-ISSUES says what was read
from source.

Thirty-five new strings, two of them plurals, in all nine catalogues.
2026-09-15 08:31:36 -07:00
jcoffey-dev 4787e8bf12 Point from the dashboard to Stalwart's own administration
A line under the cards says where the rest is: detailed metrics, the
delivery queue, logs and server settings are in Stalwart's own
administration. It links there when the operator sets STALWART_ADMIN_URL,
and stays plain text otherwise, because STALWART_URL is how this server
reaches Stalwart and is often an address no browser can open.

Several servers: a servers file entry may now be an object,
{"url": ..., "adminUrl": ...}, and a session routed to that server gets its
adminUrl. A routed domain without one gets no link rather than the default
server's, for the same reason routing never falls back. The URL is sent
only to a session that may administer.

The shipped example file stopped the server at startup: its "_comment"
key was read as a domain and refused as not a URL, while the test that
checks the example skipped it. Keys starting with an underscore are notes
now -- no mail domain starts with one -- and the example is also loaded
through the real parser in a test, so the two cannot disagree again.

Two new strings, in all nine catalogues.
2026-09-15 08:16:52 -07:00
jcoffey-dev 0054b8a3ce Open Administration on a dashboard of what the role can read
Administration used to open on its first section. It opens on a grid of
cards now: users, domains, messages waiting in the delivery queue, server
memory, and the last 24 hours' received and sent. Each card is there only
when the role holds what its number needs -- a count is a query, the
metric history a query and a get -- so a helpdesk role that reads accounts
and domains sees those two cards and nothing about the server.

What the cards count is whatever Stalwart answers for the signed-in
account, which scopes a tenant administrator's accounts, domains and queue
to the tenancy. The metric history has no tenant in it, and Stalwart's
Tenant Administrator role does not hold it, so a tenant's dashboard is
users, domains and pending.

The history is Enterprise-only and switched off by default. A server that
refuses it leaves those cards off; one that records nothing says so rather
than showing zeroes. Received and sent add up the queue counters Stalwart's
own dashboard uses, filtered with the comparison names the live server
accepts (a bare timestamp is unsupportedFilter). The column count follows
the number of cards so rows stay even, and falls back by the grid's own
width rather than the window's.

The server's test for whether an account is offered Administration matches
the client's again, now that a count is enough. The mock answers the queue
and an hourly history ending in the current hour; MOCK_METRICS=off refuses
the history as Community does, a tenant administrator gets the queue, and
helpdesk reads domains, as the demo's does.

ROADMAP and FEATURES said reporting and queues were out of scope; they say
the dashboard reads a handful of numbers and that managing queues, logs
and settings stays out. KNOWN-ISSUES records what was settled on the live
server and what was only read from source.

Fourteen new strings, in all nine catalogues.
2026-09-15 08:06:59 -07:00
jcoffey bb8d6eb92d Merge pull request #359 from Coffey-Labs/fix/keyboard-keyless-keydown
Ignore a keydown that carries no key
2026-09-15 07:29:33 -07:00
jcoffey 877566f1cd Merge pull request #358 from Coffey-Labs/fix/contacts-resizable-list
Let the contact list be resized, and give the contact the rest of the page
2026-09-15 07:28:06 -07:00
jcoffey-dev 8e9ca0498a Ignore a keydown that carries no key
Picking a saved login from Chrome's password autofill dispatches a plain
Event named "keydown", with no key on it. The shortcut listener passed it to
comboOf, which read the key's length and threw -- an uncaught TypeError in
the console on every sign-in. Harmless, since nothing was bound to it, but
it was noise that looks like a real fault.

comboOf now returns null for an event with no key, as it already does for a
bare modifier, so the listener stops there.
2026-09-15 07:27:12 -07:00
jcoffey-dev 95395200a3 Let the contact list be resized, and give the contact the rest of the page
The contacts grid still had three columns from when the address books sat
inside the view: 220px for them, 280-360px for the list, the rest for the
contact. The books moved to the app's left pane in 350f4f4 and the grid
never followed, so the list was squeezed into the books' 220px, truncating
names, the contact was held to 360px, and the remaining width sat empty.

It is two columns now, with the same splitter as the message list between
them: drag between 240px and the width that leaves 360px for the contact,
arrow keys move it, double-click puts it back to 320px. The width is a
device setting beside the mail list's, so it is kept by a device marked as
your own and never synced. The splitter is hidden where contacts show one
pane at a time. The unused .contacts-books rules are gone.

"Resize contact list" is in all nine catalogues.
2026-09-15 07:24:56 -07:00
jcoffey 14e22c21fe Merge pull request #354 from Coffey-Labs/dependabot/npm_and_yarn/jsdom-30.0.1
Bump jsdom from 26.1.0 to 30.0.1
2026-09-15 07:17:46 -07:00
dependabot[bot] b7b2455994 Bump jsdom from 26.1.0 to 30.0.1
Bumps [jsdom](https://github.com/jsdom/jsdom) from 26.1.0 to 30.0.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v26.1.0...v30.0.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 30.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-15 14:15:26 +00:00
jcoffey 7d96cf22ac Merge pull request #353 from Coffey-Labs/dependabot/npm_and_yarn/vitest-5.0.0
Bump vitest from 4.1.11 to 5.0.0
2026-09-15 07:14:09 -07:00
jcoffey a4022b37ce Merge pull request #352 from Coffey-Labs/dependabot/npm_and_yarn/lucide-react-1.45.0
Bump lucide-react from 0.477.0 to 1.45.0
2026-09-15 07:13:25 -07:00
jcoffey 33d966ba44 Merge pull request #351 from Coffey-Labs/dependabot/npm_and_yarn/concurrently-10.0.5
Bump concurrently from 9.2.4 to 10.0.5
2026-09-15 07:12:53 -07:00
jcoffey 1bf6350d68 Merge pull request #350 from Coffey-Labs/dependabot/npm_and_yarn/minor-and-patch-e9d406726e
Bump @tanstack/react-virtual from 3.14.11 to 3.14.12 in the minor-and-patch group
2026-09-15 07:11:52 -07:00
jcoffey b735685416 Merge pull request #357 from Coffey-Labs/feat/move-folder-picker
Move a folder from its menu, with the same picker as moving mail
2026-09-15 07:11:09 -07:00
jcoffey-dev 0859936f27 Move a folder from its menu, with the same picker as moving mail
A folder could only be moved by dragging it, which is slow in a long list
and not offered at all on a touch screen. Its menu now has "Move to…",
which opens the searchable folder picker that moving messages already
uses, with a "Top level" row above the folders.

The picker lists only legal destinations: the same rules as a drop -- not
into itself, its own subtree or the parent it already has -- plus the
rights a picker has to check up front because it shows every folder at
once: mayRename on the folder being moved, which RFC 8621 uses for
reparenting, and mayCreateChild on the destination. Both only say no on
shared mail. The move itself goes through the same path as a drop, so the
toast and the expanded destination are unchanged.

"Move “{name}” to…" and "Top level" are in all nine catalogues.

Closes #355
2026-09-15 07:00:12 -07:00
dependabot[bot] 1a4377de4a Bump vitest from 4.1.11 to 5.0.0
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.11 to 5.0.0.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 5.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-15 09:12:21 +00:00
dependabot[bot] 860e89fa6f Bump lucide-react from 0.477.0 to 1.45.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.477.0 to 1.45.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.45.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-15 09:12:07 +00:00
dependabot[bot] 61916b3a3a Bump concurrently from 9.2.4 to 10.0.5
Bumps [concurrently](https://github.com/open-cli-tools/concurrently) from 9.2.4 to 10.0.5.
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.4...v10.0.5)

---
updated-dependencies:
- dependency-name: concurrently
  dependency-version: 10.0.5
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-15 09:11:57 +00:00
dependabot[bot] 307752921f Bump @tanstack/react-virtual in the minor-and-patch group
Bumps the minor-and-patch group with 1 update: [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual).


Updates `@tanstack/react-virtual` from 3.14.11 to 3.14.12
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/[email protected]/packages/react-virtual)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-15 09:11:52 +00:00
jcoffey 7940a15a43 Merge pull request #349 from Coffey-Labs/i18n/template-literal-attributes
Translate English built from template literals in attributes
2026-09-14 08:28:18 -07:00
jcoffey-dev fecae2acd3 Translate English built from template literals in attributes
i18n-literals now flags a template literal in a UI attribute or prop when
there are words between its values: `Remove ${email}`, `${name} — shared by
${owner}`. It cannot be a catalogue key as written, so neither the key
exemption nor the sentence-shape test applies. A template that is only
punctuation around values, like `${name} (${size})`, is left alone.

The twelve it found are now keys with placeholders: the quota bar title,
the address menu's label, a folder's subfolder unread count, a recipient
chip's remove button, the contact editor's title, shared and available
calendars and address books, the date and time fields' labels, the
attachment title's fallback name, and the free/busy bar. The bar showed
the raw JMAP busyStatus ("confirmed") and now says Busy, Tentative or
Unavailable.

11 new keys in all nine catalogues. Remove {address}, Date, Time, Busy and
Tentative already existed.
2026-09-14 08:26:34 -07:00
jcoffey aa57c59703 Merge pull request #348 from Coffey-Labs/i18n/literal-check-component-props
Check component props for untranslated literals, and fail on a finding
2026-09-14 08:10:46 -07:00
jcoffey-dev ac4c4bd267 Check component props for untranslated literals, and fail on a finding
i18n-literals checked title, aria-label, placeholder and alt on elements,
but not the same English passed to a component, so a MenuItem label or a
Popover ariaLabel written as a literal went through. It also excused a
literal that happened to be a catalogue key. That exemption is meant for
English held in a constant and translated where it renders, and a literal
written straight into a JSX attribute has no such render site. No
component passes its props through t(). And neither half of i18n:check
was run with --check, so a finding printed and the script still exited 0.

Component props are checked now, a key no longer excuses a literal in an
attribute, and both scripts run with --check. That found 28 strings
rendering English in every language: 19 already had keys and are wrapped,
and 9 are new keys in all nine catalogues. The contact editor's Save and
Saving… buttons are wrapped as well, on the same line.
2026-09-14 08:03:12 -07:00
jcoffey af092b3942 Merge pull request #347 from Coffey-Labs/feat/resizable-sidebar
Let the sidebar be resized by dragging its edge
2026-09-14 07:56:59 -07:00
jcoffey f0039c87fe Merge pull request #346 from Coffey-Labs/ci/weekly-release-off-the-hour
Move the weekly release off the top of the hour
2026-09-14 07:56:52 -07:00
jcoffey-dev abb07acc80 Let the sidebar be resized by dragging its edge
The sidebar's right edge is now a splitter, like the one between the
message list and the reading pane: drag it between 240 and 480px, move it
with the arrow keys, double-click to put it back. The width is a device
setting, and stays null until someone drags, so a width set in the
reader's own CSS through --sidebar-w is kept until they choose otherwise.
Hidden on a phone, where the sidebar is a drawer, and while collapsed.

Arrow keys on either splitter moved the pane and never saved it: the
keyboard path called onResize without onEnd. It ends each key press now,
and both views keep the in-progress size in a ref as well as state, so
the end reads the value set in the same tick.

The message-list splitter's accessible name was an untranslated literal.
It goes through translate() now, and it and the sidebar's new name are in
all nine catalogues.

Closes #345
2026-09-14 07:54:56 -07:00
jcoffey-dev e1f0904184 Move the weekly release off the top of the hour
The 09:00 UTC schedule competes for GitHub's busiest slot. The first
scheduled run started almost six hours late, and on 2026-09-14 no run had
started four and a half hours in, so that week was cut by hand. 09:17 is
still best-effort, but no longer queues behind every on-the-hour schedule.
2026-09-14 06:40:07 -07:00
jcoffey 1dc0caeae9 Merge pull request #344 from Coffey-Labs/docs/companion-tools
Point to the companion tools near the top of the README
2026-09-13 22:48:01 -07:00
jcoffey-dev 9647ead8d4 Point to the companion tools near the top of the README
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.
2026-09-13 22:46:14 -07:00
jcoffey c587268c97 Merge pull request #343 from Coffey-Labs/docs/features-known-issues-0.16.22
Bring FEATURES and KNOWN-ISSUES up to 0.16.22
2026-09-13 18:00:26 -07:00
jcoffey-dev ce683f94bd Bring FEATURES and KNOWN-ISSUES up to 0.16.22
Both said the live instance runs 0.16.21. KNOWN-ISSUES now records that
0.16.22 was tested and lists what it changed for a client, and notes the one
entry it touches: an event read by its stored id reports a null baseEventId,
while a one-off from an expanded query still carries a base.

The shareWith entry still described calendars and address books leaving the
field out, which 0.16.21 fixed; it now says so, and that Mailbox/get alone
still omits it.
2026-09-13 17:58:50 -07:00
jcoffey 3dd8c7c2dd Merge pull request #342 from Coffey-Labs/mock/stalwart-0.16.22
Follow Stalwart 0.16.22 in the mock
2026-09-13 17:57:27 -07:00
jcoffey-dev de71572b9d Follow Stalwart 0.16.22 in the mock
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.
2026-09-13 17:55:36 -07:00
jcoffey 029afc21c4 Merge pull request #341 from Coffey-Labs/docs/stalwart-0.16.22
Say 0.16.22 in the README
2026-09-13 17:52:51 -07:00
jcoffey-dev 64dbb30e70 Credit the testing to 0.16.22, where it was done
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.
2026-09-13 17:50:43 -07:00
jcoffey-dev 28acad6865 Say 0.16.22 in the README, and what it changed for a client
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.
2026-09-13 17:49:08 -07:00
jcoffey 5f808f3033 Merge pull request #340 from Coffey-Labs/fix/translate-admin-errors
Say every Administration refusal in the reader's language
2026-09-13 17:21:07 -07:00
jcoffey-dev 15b1838e21 Say every Administration refusal in the reader's language
Stalwart explains a refused change in English, and several of its words
reached the page as they were: "Invalid domain name" for a reserved TLD,
"Invalid email address" for a catch-all, a grant refusal, and ihasmail's own
proxy messages. Every registry error type now has its own message, and a
value one of the registry's string validators refused is recognised by the
validator's wording and explained again. A domain clash or a missing domain
is worded for a domain rather than an account.

The one exception is kept on purpose: a password policy's reason follows a
translated sentence, because the rule is the server's and dropping it would
leave no way to find out why.

The mock now refuses a reserved TLD and a catch-all without a domain the way
the live server did. KNOWN-ISSUES records the fix, and that the last two live
cases -- an administrator-set password and the outranking guard -- held.

15 new strings in all nine catalogues, 3 retired; strings falling back to
English stay at 16.
2026-09-13 17:18:15 -07:00
jcoffey 822314e8b7 Merge pull request #339 from Coffey-Labs/docs/administration
Record what Administration proved on the live server
2026-09-13 16:49:13 -07:00
jcoffey-dev 5027bd1e73 Record what Administration proved on the live server
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.
2026-09-13 16:45:18 -07:00
jcoffey f44987e391 Merge pull request #338 from Coffey-Labs/feat/admin-trusted-device-only
Offer administration only on a device marked as your own
2026-09-13 16:36:45 -07:00
jcoffey b6cc762d23 Merge pull request #337 from Coffey-Labs/feat/admin-nav-in-sidebar
Move Administration's section list into the folder pane
2026-09-13 16:36:22 -07:00
jcoffey-dev f1638b2fee Offer administration only on a device marked as your own
A session signed in without "This is my own device" can no longer
administer. The server withholds the account's permissions from it and the
JMAP proxy refuses registry methods beyond the account's own, the same gate
ADMINISTRATION=0 uses. A borrowed or shared machine is where nobody should
be able to reset a password or remove a domain.

An administrator in such a session still sees Administration in the account
menu, greyed out, with the reason and the fix: sign in again with the box
ticked. The server tells that session only that the account administers.

The gate now reads the body only when it could name a registry method --
"x: in the text, or a \u escape that could spell one -- so ordinary mail
traffic from an untrusted session is forwarded untouched.

1 new string, translated in all nine catalogues, quoting each language's own
label for the tickbox; strings falling back to English stay at 16.
2026-09-13 16:34:20 -07:00
jcoffey-dev 7c0e278ee8 Move Administration's section list into the folder pane
Administration's pages are tables, and the Settings-style second column
took width they need. The list of sections -- Directory > Accounts,
Mail > Domains -- now sits in the folder pane where Mail keeps its folders,
and the page is the open section alone, up to 1120px wide.

On a phone the list is in the drawer like every other section's, so a bare
/admin opens the first section rather than a page that is only a list. The
back link it needed is gone.
2026-09-13 16:27:30 -07:00
jcoffey 93c9660421 Merge pull request #336 from Coffey-Labs/fix/account-type-filter
Filter accounts on @type, the name Stalwart uses
2026-09-13 15:58:52 -07:00
jcoffey-dev 430fc2673c Filter accounts on @type, the name Stalwart uses
The Accounts list sent x:Account/query with {"type": "User"}, and a live
0.16 server refuses it: "unsupportedFilter - type". The registry keys a
filter by the property's name on the object, which for the discriminator is
@type, so the whole list failed to load. {"@type": "User"} is accepted.

The mock took the wrong name without complaint, which is how it shipped. It
now refuses any filter name the real server does not index for that object,
answering the way Stalwart does.
2026-09-13 15:56:16 -07:00
jcoffey b79db9098a Merge pull request #335 from Coffey-Labs/feat/admin-domains
Add Domains to Administration
2026-09-13 15:47:30 -07:00
jcoffey 16e0761ddf Merge pull request #334 from Coffey-Labs/feat/admin-accounts
Add Administration, starting with accounts
2026-09-13 15:46:46 -07:00
jcoffey 5f5672fed3 Merge pull request #333 from Coffey-Labs/fix/multi-server-account-requests
Send account requests to the account's own Stalwart
2026-09-13 15:46:20 -07:00
jcoffey-dev 1dafb4bc79 Add Domains to Administration
A role that can read domains now finds a Domains section beside Accounts:
list and search with each domain's account count and whether its DNS, DKIM
and certificate are managed automatically; add a domain; edit its
description, other names, catch-all address and plus addressing; copy its
DNS records one at a time or as a zone file; see its DKIM keys and their
stage; and remove it once no accounts use it.

The records come from the zone file Stalwart computes per domain. A long
DKIM record, which the BIND serialiser splits into quoted chunks, is joined
back into the single value a DNS provider's form wants.

Removing a domain takes its DKIM keys first, in the same request, because the
server will not remove a domain its keys still name. Removal is not offered
while accounts use the domain, or when the role cannot remove the keys.

The Administration nav is now built from the sections the role can read, and
the menu appears when there is at least one. The mock gains domains, DKIM
keys and zone files.

61 new strings, translated in all nine catalogues; strings falling back to
English stay at 16.
2026-09-13 15:39:16 -07:00
jcoffey-dev d279fe8f90 Let an operator turn administration off
ADMINISTRATION=0 at launch removes in-app administration for everyone. The
account's permissions are no longer sent to the browser, so the menu never
appears, and the JMAP proxy refuses Stalwart registry methods other than the
account's own (settings, password, app passwords, API keys, public keys,
masked addresses). Hiding the menu alone would have left an administrator's
browser console able to make every call the menu made.

With administration on, the request body streams through untouched as before;
only an installation that turns it off reads and checks the body, forwarding
the parsed form so the server receives exactly what was inspected.
2026-09-13 15:27:05 -07:00
jcoffey-dev 82e217155b Add Administration, starting with accounts
An account whose Stalwart role manages accounts now finds Administration in
the account menu. It lists, searches, creates and edits accounts -- display
name, other addresses, role, storage limit -- sets a new password, and
deletes, each offered only when the role holds the matching permission.

The server keeps the permissions list from GET /api/account, which it
already called for the edition and threw the rest away. Everything else is
JMAP x:Account, x:Domain and x:Role calls through the existing /api/jmap
proxy, so nothing new is stored and Stalwart decides every call.

Stalwart checks a grant against the caller's permissions but not a password
change or a delete, so an account that outranks the viewer is shown
read-only. Your own password is changed in Settings, which re-seals the
session; changing it here would strand it.

The mock server gains a directory behind the same permission names, with
MOCK_ROLE choosing admin, tenant-admin, helpdesk or user.

68 new strings, translated in all nine catalogues; strings falling back to
English stay at 16.
2026-09-13 15:11:55 -07:00
jcoffey-dev b5c073955d Send account requests to the account's own Stalwart
Two requests ignored the domain mapping from #238 and went to STALWART_URL:

- /api/account/* re-fetched the upstream session without upstreamFor(), so
  once the five-minute session cache expired, password, app-password and
  2FA calls for a mapped domain reached the default server.
- The locale lookup resolved Stalwart's apiUrl against the default server
  rather than the one that issued the session.

Both now use the session's own server, with a test pinning the second.
2026-09-13 14:45:33 -07:00
jcoffey b0564679e6 Merge pull request #332 from Coffey-Labs/deploy-version-without-node
Let the deploy compute its version without node
2026-09-12 12:29:25 -07:00
jcoffey-dev 724ff0b077 Let the deploy compute its version without node
The deploy script asked node for the build's version string, and that was
the only thing it needed node for. A host that runs everything as
containers has git and docker and nothing else, and today's deploy stopped
at 'node: command not found' before building anything.

The version is the same sum scripts/version.mjs does -- the commit's own
date plus the pull request it arrived through, or its short SHA -- done in
shell. Checked against the script on a merge commit, a plain commit and an
older one; all three agree.
2026-09-12 12:27:22 -07:00
jcoffey a666cdbcdc Merge pull request #331 from Coffey-Labs/fix/composer-maximized-stacking
Paint a full-screen composer above the others
2026-09-12 12:24:34 -07:00
jcoffey-dev 5855da0ba9 Paint a full-screen composer above the others
A maximised composer goes position: fixed but stays a child of the dock,
and had no z-index of its own. The dock is a stacking context, so the
positioned parts of any composer later in the DOM (its recipients row, its
editor) painted straight over the full-screen one.

Give the maximised composer its own layer, and hide the other composers
while one is full screen: they cannot be reached anyway, and the 24px
inset would otherwise show their footers along the bottom edge. They stay
mounted, so nothing being written in them is lost.

Fixes #330
2026-09-12 12:20:58 -07:00
jcoffey c3d2dc2418 Merge pull request #329 from Coffey-Labs/prune-stale-catalogue-keys
Report the stale keys that are stale, and remove them
2026-09-10 16:01:41 -07:00
jcoffey-dev 54b316ae36 Report the stale keys that are stale, and remove them
The check reported 41 stale keys per catalogue. Ten of them were.

The other 31 were strings held in constants and translated where they render --
t(b.description), t(group), t(c.label) -- so they reach t() as a variable and
there is no literal at the call site to find. The script already chased two of
those shapes, `label:` and objects named *_LABELS, with a comment about crying
wolf 33 times. The shapes kept coming: `description:` and `group:` on the
keyboard bindings, the calendar's view names, the read-receipt refusals, the
palette names.

Chasing them one at a time is the wrong shape of fix. Stale detection now asks
only "is this key still written down anywhere in the source" -- any string
literal counts. That under-reports, and that is the right way round: a missed
stale key costs a line of dead translation, a false one costs the credibility
of the check and every real finding after it. Which is what happened here --
these sat unread long enough to need a commit of their own.

Coverage keeps the strict set. The two questions need different nets, and
widening the one that measures what a catalogue *owes* would count every CSS
class and JMAP method name as an untranslated string -- it read 29% while I had
them sharing a set. `wanted` is the obligation, `seen` is the evidence.

What was actually dead, removed from all nine: "Availability on {date}",
"Import vCard", "PDF", two settings hints replaced by rewordings that are still
live, the Catppuccin palette description, and Tuesday through Friday -- left
behind when the week-start dropdown narrowed to the three days a week actually
starts on, and appearing since only in comments.

Coverage is unchanged at 1269/1285: none of the ten was ever owed.
2026-09-10 16:00:00 -07:00
jcoffey 3c4f6a9f8e Merge pull request #328 from Coffey-Labs/i18n-scripts-typescript7
Give the i18n scripts a parser again
2026-09-10 14:24:06 -07:00
jcoffey 2a323d6270 Merge pull request #327 from Coffey-Labs/single-message-view
Let conversation view off mean off
2026-09-10 14:23:52 -07:00
jcoffey-dev d282813bc5 Give the i18n scripts a parser again
TypeScript 7 is the native port: the package ships a `tsc` shim over a Go
binary, and `typescript` now exports `version` and `versionMajorMinor` and no
compiler API. Every `ts.createSourceFile` in scripts/ has been throwing
"Cannot read properties of undefined (reading 'Latest')" since the 5.9.3 → 7.0.2
bump -- four of the five i18n scripts dead, only i18n-extract still running.

Nothing noticed because no workflow runs them. The catalogue gate for nine
languages has been dark, and the only signal was running it by hand.

There is no official TS7 API package (@typescript/ast and @typescript/api are
both 404), and the alternative was rewriting 493 lines and 25 distinct AST
calls, including the JSX guards, against a different tree -- in tooling with no
tests of its own. So `typescript-ast` is an npm alias for the last TypeScript
carrying the JS API. It parses; `typescript` still type-checks and builds. Two
entries, two jobs, said so in each script so the next reader does not delete
one as a leftover.

What the gate says now it can speak: catalogues are green and coverage is 100%.
The "16 falling back to English" it reports in every locale are placeholders,
example domains, a product name, a licence id and the quote glyph -- strings
that should stay English. The 41 stale keys per locale are real dead weight and
are left for their own change.
2026-09-10 14:21:42 -07:00
jcoffey-dev d599e7404f Let conversation view off mean off
The setting reached only as far as the query. It set `collapseThreads`, so the
list correctly showed individual messages -- and then everything downstream
carried on working in threads. Opening one message highlighted every row of its
thread and filled the reading pane with the whole conversation, which is the
grouping the setting was turned off to avoid. The empty pane went on offering
"62 conversations" either way.

Three places had to learn about it, and the two rules behind them now live
together in lib/openMessage.ts:

- the row highlight matched on threadId, so siblings lit up
- ThreadView rendered every message the thread held
- the empty state named conversations regardless

The thread id stays in the path and loading is unchanged; the opened message
rides in `m`. Keeping it in the URL rather than in memory is what makes a
reload or a shared link come back to the same message, and an id that names
nothing in the thread falls back to the conversation -- which is what a link
from somebody with the setting on looks like, and what a stale parameter looks
like after switching back. Better a conversation than an empty pane.

Nine catalogues gain "No message selected" and "Select a message to read it
here"; "{n} messages" was already there, plural forms and all.
2026-09-10 14:15:50 -07:00
jcoffey 9b497af756 Merge pull request #326 from Coffey-Labs/plain-text-line-breaks
Render plain-text mail with its line breaks
2026-09-10 13:39:12 -07:00
jcoffey-dev 73a1bad29f Render plain-text mail with its line breaks
`htmlBody` is a derived list, not a filter: RFC 8621 §4.1.4 gives a message
with no HTML alternative one anyway, holding the text/plain part. Testing
`Boolean(htmlRaw)` therefore answered "this is HTML" for every plain-text
mail, sending it to HtmlBody and `.ihm-email-root`, which is
`white-space: normal` and collapses every line break. Hard-wrapped mail
arrived as a single paragraph with the signature and the quoted reply run
into the prose.

Confirmed live against Stalwart 0.16.21 (2026-09-10): a plain-text message
comes back with `htmlBody` and `textBody` naming the same part, typed
text/plain, while a real multipart/alternative names two different parts.
`type` was already in BODY_PROPS; nothing looked at it.

TextBody was written for exactly these messages and was simply unreachable,
so this also restores what it does -- pre-wrap, quote-depth colouring and the
collapsible quoted block, none of which had ever fired on plain-text mail.
2026-09-10 13:32:37 -07:00
jcoffey a9923c48d9 Merge pull request #325 from Coffey-Labs/funding-username-jcoffey-dev
Point the Sponsor button at the current GitHub username
2026-09-10 09:19:28 -07:00
jcoffey-dev 5b21720312 Point the Sponsor button at the current GitHub username
The account behind it was renamed from LINUXexpert-org to jcoffey-dev,
and GitHub does not redirect the old name: github.com/sponsors/
LINUXexpert-org answers 404 while the new one answers 200. So the
Sponsor button on this repository has been leading nowhere.

Worth fixing rather than leaving to redirect, because a released
username can be registered by anyone -- a stale link stops being a dead
end and starts being someone else's page.
2026-09-10 09:17:02 -07:00
Coffey Labs f2aaa9cea4 Merge pull request #324 from Coffey-Labs/node-26
Move CI, the image and the Node types to 26 together
2026-09-10 08:50:27 -07:00
jcoffey-dev 6632231815 Move CI, the image and the Node types to 26 together
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.
2026-09-10 08:44:55 -07:00
Coffey Labs 7d9d5b005c Merge pull request #323 from Coffey-Labs/hono-node-server-2
Take @hono/node-server to 2.1.1
2026-09-10 08:40:20 -07:00
jcoffey-dev 23738501c7 Take @hono/node-server to 2.1.1
All three entry points we import survive the major unchanged: `serve`
keeps its `(options, listeningListener)` signature and still accepts
`fetch`, `hostname` and `port`; `RESPONSE_ALREADY_SENT` is still exported
from `utils/response`; `getConnInfo` is still on `conninfo`. The peer is
hono ^4 and the engine >=20, both of which we already meet.

What v2 adds is two defaults worth knowing about. `overrideGlobalObjects`
swaps in a lighter Request/Response, and `autoCleanupIncoming` destroys
an incoming request the app never finished reading -- which is the
behaviour you want behind a proxy, and is on by default.

Neither is something the unit tests would notice, so this was run rather
than reasoned about. Against the mock: login, an /api/events stream, and
a POST carrying a body through to upstream. The SSE path is the one that
matters, since it writes to the raw ServerResponse and hands back
RESPONSE_ALREADY_SENT; it answers with the same headers, the same
chunked encoding and the same bytes as 1.19.17 does on the same script.
2026-09-10 08:28:21 -07:00
Coffey Labs 91dda348bc Merge pull request #318 from Coffey-Labs/dependabot/npm_and_yarn/typescript-7.0.2
Bump typescript from 5.9.3 to 7.0.2
2026-09-10 08:19:18 -07:00
dependabot[bot] 3240c56e84 Bump typescript from 5.9.3 to 7.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-10 15:16:28 +00:00
Coffey Labs 136c754bcd Merge pull request #322 from Coffey-Labs/vite-8
Move the build to vite 8, and chunk the way rolldown wants
2026-09-10 08:14:33 -07:00
Coffey Labs aa0d594666 Merge pull request #321 from Coffey-Labs/tsconfig-relative-paths
Point the @ alias at a relative path, and drop baseUrl
2026-09-10 08:14:25 -07:00
jcoffey-dev 53ccaad468 Move the build to vite 8, and chunk the way rolldown wants
@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.
2026-09-10 08:03:40 -07:00
jcoffey-dev d51d523ce1 Point the @ alias at a relative path, and drop baseUrl
TypeScript 7 removes `baseUrl` outright and refuses a non-relative entry
in `paths`, so the typecheck stops on tsconfig.json before it reaches a
line of our code. A leading `./` says the same thing without it: paths
resolve against the tsconfig's own directory, which is what `baseUrl:
"."` was there to arrange.

Nothing here waits for the upgrade. Relative paths without a baseUrl
have been the supported spelling since 4.4, so this typechecks the same
under 5.9.3 today as it will under 7. Vite resolves `@` from its own
alias in vite.config.ts and never read this.

With this in, 7.0.2 typechecks both workspaces clean -- the two
tsconfig errors were all that stood in the way, not the first two of
many.
2026-09-10 07:57:57 -07:00
Coffey Labs d90a1cef93 Merge pull request #314 from Coffey-Labs/dependabot/npm_and_yarn/minor-and-patch-897b8e30fb
Bump the minor-and-patch group across 1 directory with 4 updates
2026-09-10 07:02:31 -07:00
Coffey Labs 829c5ab14d Merge pull request #316 from Coffey-Labs/dependabot/github_actions/actions-819308dff6
Bump the actions group with 7 updates
2026-09-10 07:02:23 -07:00
dependabot[bot] 1b9058fccb Bump the minor-and-patch group across 1 directory with 4 updates
Bumps the minor-and-patch group with 4 updates in the / directory: [tsx](https://github.com/privatenumber/tsx), [dompurify](https://github.com/cure53/DOMPurify), [wouter](https://github.com/molefrog/wouter) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


Updates `tsx` from 4.23.12 to 4.23.13
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.12...v4.23.13)

Updates `dompurify` from 3.4.14 to 3.4.15
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.14...3.4.15)

Updates `wouter` from 3.10.0 to 3.11.0
- [Release notes](https://github.com/molefrog/wouter/releases)
- [Commits](https://github.com/molefrog/wouter/commits/v3.11.0)

Updates `@types/react-dom` from 19.2.4 to 19.2.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: wouter
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-10 13:54:14 +00:00
Coffey Labs a2ae868f28 Merge pull request #320 from Coffey-Labs/deps-vitest-4
Take vitest to 4.1.11, and stop two suites leaking their spies
2026-09-10 06:51:36 -07:00
jcoffey-dev 520de85d12 Take vitest to 4.1.11, and stop two suites leaking their spies
GHSA-82fw-gwwq-j7x9 -- arbitrary file read through @vitest/mocker's
redirect mock -- has no fix in the 3.x line. The patched versions are
4.1.11 and 5.0.0-rc.2, so clearing it means the major. vite stays at
6.4.3: vitest 4 accepts ^6, and nothing outside devDependencies moves.

The bump surfaced a bug of ours rather than one of vitest's. vi.spyOn
now hands back the spy already installed on a method instead of wrapping
it in a fresh one, so a spy installed in beforeEach keeps its call count
across tests. compose-from-share expected two uploads and saw three: its
own two, plus the one from the test before it. The assertion was only
ever passing because each test happened to get a new spy.

Both suites now restore between tests, which is what the other five
spying suites already do. webpush had the same leak with no assertion
close enough to catch it.
2026-09-10 06:41:19 -07:00
dependabot[bot] 36ad85feef Bump the actions group with 7 updates
Bumps the actions group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `7` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `7` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `actions/setup-node` from 4 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-10 13:26:33 +00:00
Coffey Labs 9418d3f935 Merge pull request #312 from Coffey-Labs/deps-hono-4.13.7-dependabot-config
Take hono to 4.13.7 and let Dependabot open the next one
2026-09-10 06:25:00 -07:00
jcoffey-dev 3e8b1ebb38 Take hono to 4.13.7 and let Dependabot open the next one
Three medium advisories land on hono before 4.13.5: a toSSG() path
escape, a query parser that reads parameters past the URL fragment, and
unbounded dot-notation nesting in parseBody(). Only the second one
touches this server -- c.req.query() is read in imageproxy, icsproxy and
app -- and even there safeFetch validates the value it actually fetches
rather than a separate pre-check, so there was nothing to desync. toSSG
and parseBody are never called. The bump is still worth taking on its
own: it is a patch release with no API change.

The declared range moves with it, from ^4.7.4 to ^4.13.7, so the
security floor is recorded in server/package.json and not only in the
lockfile.

The dependabot.yml is the actual fix for how these were found. There was
no config, so nothing opened a PR and the alerts sat on a dashboard
until someone thought to look. Routine updates now group into one PR a
week; majors stay separate, because they are migrations.
2026-09-10 06:21:56 -07:00
Coffey Labs 38fb78a095 Merge pull request #311 from Coffey-Labs/theme-nested-light-panels
Neutralise light panels nested inside dark painted cards
2026-09-08 21:28:52 -07:00
jcoffey-dev 6f3aba06a6 Neutralise light panels nested inside dark painted cards
Closes #310.

A dark campaign rendered with beige cards inside it. markKeptSurfaces
marks any element whose declared background is below the luminance
threshold, with no area cap, so a 600px layout card is marked exactly
like a button. The CSS then exempted the marked element and its whole
subtree via [data-ihm-keep] *, so a light table nested in that card was
never touched. In the reported specimen 14 of 21 light panels survived.

The rule now is that being inside a painted surface is not inherited past
a sheet. The walk tracks that state and emits a second mark,
data-ihm-in-keep, for elements sitting on paint with no background of
their own; the CSS exempts those explicitly instead of exempting every
descendant. A nested light sheet ends the protection, and paint resumes
below it, so a button inside such a sheet is still kept whole.

The alternatives in the report were not taken. Dropping the descendant
half of the selector outright puts back what #294 fixed: a nested label
on a coloured cell loses its colour. An area threshold is a magic number
that misfires on both a legitimate hero banner and a small dark panel
with a light chip in it.

The tests assert against the neutraliser selector lifted out of
EMAIL_BASE_CSS rather than against the marks. The first draft of them
checked which attributes were set and passed against the unfixed code,
which proved nothing: the bug was in the rule that reads the marks, not
in the marking. All four fail without this change.
2026-09-08 19:32:09 -07:00
Coffey Labs a389b9e8c5 Merge pull request #309 from Coffey-Labs/sw-must-not-be-cached
Never serve the service worker from a cache
2026-09-07 23:04:59 -07:00
jcoffey-dev 0ad19802f8 Never serve the service worker from a cache
The deploy on 2026-09-08 went out at the origin and did not arrive.
Cloudflare went on handing out the previous `sw.js` -- `cf-cache-status:
HIT`, with an edge TTL of four hours, longer than the hour we asked for
-- because the file is neither a hashed asset nor HTML and so fell into
the ordinary `max-age=3600` case.

That is not a freshness preference. The service worker is the app's whole
update mechanism: a browser holding the old one goes on being served the
shell that worker knows and never learns a deploy happened, so the deploy
simply does not land. The manifest matters for a second reason -- the two
have to agree. A fresh manifest advertising a share target, answered by a
worker that has never heard of one, sends the share to the server for a
405. Either being old is survivable; disagreeing is not.

`no-cache` rather than `no-store`: both may still keep a copy, they just
have to revalidate it, which is a 304 and costs nothing. Neither gets to
answer with its own copy without asking.

Narrow on purpose -- two files, named, rather than a policy that quietly
stops the icons and fonts being cached as well.
2026-09-07 23:02:19 -07:00
Coffey Labs 1592f38515 Merge pull request #308 from Coffey-Labs/notification-actions
Archive and mark read from the notification itself
2026-09-07 22:59:14 -07:00
jcoffey-dev acc50f009c Archive and mark read from the notification itself
Both happen in the background. The phone stays where it is.

This was twice described as impossible, here and in FEATURES.md: the
service worker was said to have no session, so anything touching mail had
to open the app. That is wrong, and checking it rather than repeating it
is the whole of this change. ihasmail's session is an httpOnly cookie
against its own origin and the only other thing the API asks for is a
fixed `x-requested-with` header, which is not a secret and is not held
anywhere. A same-origin fetch from the worker carries the cookie like any
other. Confirmed against the mock: logging in with curl and then issuing
`Email/set` with nothing but that cookie and the static headers marked a
message read and moved it to Archive, HTTP 200. Nothing the tab holds in
memory is involved, because the API asks for none of it.

Two actions, because `maxActions` is two on Android and anything past it
is dropped without a word. Archive and Mark as read are the two worth
having: they are what somebody does to a notification they have already
read the whole of. Reply is not among them -- it would have to open the
app, which is what tapping the notification does already.

The worker still cannot reach a catalogue. It is plain JavaScript copied
into the build, outside the bundle, with no i18n and no idea which
mailbox is the archive. So the app writes both down in the same cache it
already uses for handoffs, and rewrites them whenever the language, the
account or the folder list changes. Where there is no such note -- between
installing this worker and next opening ihasmail -- the notification
appears with no buttons at all, rather than English ones over a mailbox
guessed by name. That also fixes two strings the worker had always shown
in English regardless: "New mail" and "(no subject)".

A session can be gone by the time a button is pressed. That comes back as
a refusal and the notification says so, rather than vanishing as though
it had worked. It does not open the app to recover: being interrupted is
what the button existed to avoid.

The two claims that were wrong are corrected rather than quietly deleted,
including the one about push renewal -- which still needs a tab, but for
a different reason than the one given. The reason is when the worker
runs, not what it may do: it wakes only for a push, and the push stops
when the subscription lapses.

Two new strings, in all nine catalogues.
2026-09-07 22:55:55 -07:00
Coffey Labs f42fb014c8 Merge pull request #307 from Coffey-Labs/share-target
Be somewhere a phone can share to
2026-09-07 22:46:24 -07:00
jcoffey-dev a73525f425 Build the shared file from its bytes, not from a Blob
CI caught this on Node 22 while it passed here on 26. `new File([blob],
…)` only puts the blob's contents in the file where that implementation
recognises a Blob as a part; where it does not, it stringifies it, and
the file contains the thirteen characters "[object Blob]". No error
anywhere -- the name, the type and the attachment are all correct and
the contents are gone.

A browser would not have done this. It is worth not relying on that: an
ArrayBuffer is a part on every implementation, and the whole file is in
memory a moment later regardless, since it is about to be uploaded.
2026-09-07 22:43:30 -07:00
jcoffey-dev 82470e8db0 Be somewhere a phone can share to
ihasmail could hand a file to the share sheet as of #306, and was still
not in it. Share a photo from the gallery, a link from the browser or a
document from a file manager and ihasmail was not among the places it
could go, which is the one piece of operating-system integration a mail
app is expected to have.

A share is a POST that navigates, and there is nothing on this side that
can answer one: the app is a client-side router with no endpoint at that
address, and the server behind it would need a route that understood the
composer. So the service worker intercepts it, takes the form body, puts
the files and text in its cache, and redirects to the app -- which finds
them on start and opens a draft holding them. The subject is the shared
title, the text and the link become the body, and files are attached and
begin uploading. Nothing is addressed: a share says what to send, never
who to.

The body is pushed in above the signature rather than passed to open(),
because open() only fits a signature when it is given no body at all --
the obvious version drops the signature from every message that started
as a share, and nothing about the draft looks wrong afterwards.

Collected on every start rather than when the launch URL says so. A share
to a signed-out ihasmail lands on the sign-in page, and there is no
account to attach to until it is done, so the payload has to outlive a
redirect and a login -- which the query string does not. What that costs
is a stash nobody came back for, so it carries a timestamp and expires
after ten minutes.

`accept` names wildcard families and explicit types and extensions both.
A mail client attaches anything, but wildcards are not in the
specification and operating systems differ over which form they match on,
so the explicit list is what holds if the families are ignored.

The cache name the worker and the app have to agree on now has one home
on the app side. It was written out twice, and a drift would not fail --
a push verification would simply never complete and a share would arrive
at an empty composer.

One case is deliberately left to fail loudly: an app still installed
whose worker has been cleared away POSTs to the server, which answers
405. A server route would trade a plain error for a silent nothing, and
the payload is gone in both -- it only ever existed in that request body.

Verified by test, not on a device: Android is the only place this exists
at all, and the extension driving Chrome is not connected here. The
handoff is pinned from the tab's side against a cache shaped exactly as
the worker leaves it, since the two files never see each other.
2026-09-07 22:40:44 -07:00
Coffey Labs f39d6ac30c Merge pull request #306 from Coffey-Labs/mobile-share-and-app-badge
Badge the installed icon, and share to the phone rather than to Downloads
2026-09-07 22:32:39 -07:00
jcoffey-dev 4e61adfe80 Badge the installed icon, and share to the phone rather than to Downloads
Three things an installed ihasmail did not do that a phone user expects,
and all three are about the app once it is off the browser tab.

The unread count was painted into the tab title and the favicon, neither
of which exists in `display: standalone` -- so putting ihasmail on a home
screen threw the count away entirely. It goes to the Badging API as well
now. Web Push marks the icon while the app is closed, and marks it with a
dot rather than a figure: the service worker has no session to ask how
many messages are unread, and a push carries the new mail rather than a
total, so counting the payload would badge "2" over an inbox holding
forty. The next tab to open writes the real count over it.

Sharing is new. Everything that left ihasmail left as a download, which
on a phone is close to a dead end -- the file lands in Downloads and
whoever meant to send it somewhere goes looking for it in a file manager.
The share sheet is now on the message menu, on each attachment row, and
in the file viewer, which is where an attachment is already open and
where both callers meet. A message shares as text rather than as the
.eml beside it: a share sheet is aimed at everything that is not a mail
client, and an .eml in a chat app is an attachment nobody can open.

Every control feature-detects, and sharing a file is a separate question
from sharing at all -- desktop Linux and Firefox have neither, and not
every browser with `share` takes files. Anything that fails, including
the transient activation running out while a large attachment is fetched,
falls through to the download the button sits beside, so the worst case
costs a tap rather than the file. `NotAllowedError` is reported as
unsupported for that reason: it cannot be told apart from a refusal, and
a toast about activation is not something a reader can act on.

The share strings are contextual keys rather than the existing "Share…".
That one means granting another account access, and several languages use
a different verb for it -- German had "Freigeben" where the sheet wants
"Teilen". Three new strings, in all nine catalogues.

The manifest gains `launch_handler: navigate-existing`, so a mailto:, a
shortcut or a notification tapped while ihasmail is running arrives in
the copy that is running: two windows on one inbox disagree about what
has been read. `focus-existing` would have been wrong -- it only focuses
and leaves the target URL to launchQueue, which nothing here consumes, so
it would swallow the mailto. There is deliberately still no `id`, and the
manifest now says why: it is the one member resolved against the origin
of start_url rather than against the manifest's own address, so no
relative form can name a subpath mount, and the default id already is
start_url -- writing one now would give every installed copy a new
identity and orphan it as a second app.

Verified by test rather than on a device: the extension driving Chrome
was not connected, and Chrome on Linux has no Web Share to drive anyway.
The preview dialog is covered by a component test that stubs the browser
both ways.
2026-09-07 22:28:17 -07:00
Coffey Labs b65732ea04 Merge pull request #304 from Coffey-Labs/settings-ownership-wording
Say who owns settings.json, not just where it lives
2026-09-07 18:07:30 -07:00
jcoffey-dev 166a04f578 Say who owns settings.json, not just where it lives
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.
2026-09-07 18:05:16 -07:00
Coffey Labs 79d891c623 Merge pull request #303 from Coffey-Labs/contributor-notes-in-contributing
Move the translation and UI-verification notes into CONTRIBUTING
2026-09-07 16:49:29 -07:00
jcoffey-dev d13cf6ed6b Move the translation and UI-verification notes into CONTRIBUTING
The plural-key gotcha and the reasons store tests miss visible bugs are
contributor guidance, not a side file: they belong next to the rest of
the pull-request checklist where anybody sending a change will read them.
2026-09-07 16:47:12 -07:00
Coffey Labs b56ffdf268 Merge pull request #302 from Coffey-Labs/claude-md-scope
Limit CLAUDE.md to translations and verifying UI work
2026-09-07 16:43:35 -07:00
jcoffey-dev 0ebdb38a98 Limit CLAUDE.md to translations and verifying UI work
The file was added without being asked for. It stays, but with its scope
stated at the top so it does not grow into a second contributor guide:
the nine catalogues and what it takes to confirm a visible change works,
and nothing else.
2026-09-07 16:41:10 -07:00
Coffey Labs 35935f2d3c Merge pull request #301 from Coffey-Labs/calendar-reimport-updates
Update a re-imported event rather than skipping it
2026-09-07 13:07:29 -07:00
jcoffey-dev 8173e22ccb Update a re-imported event rather than skipping it
Contacts and calendars disagreed on a re-import: a vCard or LDIF entry
whose identity a book already held overwrote the card there (#242, #274),
while an event whose UID a calendar held was counted and thrown away
(#222). The asymmetry was never decided -- it was where each half stopped.

Decided on #279: calendars update too, with two properties held back.
`participants` carries every attendee's accepted/declined and
`recurrenceOverrides` holds every "just this Wednesday" edit made here.
Both are decisions taken after the file was written, and a file that
mentions them at all describes them as they were at export, so writing
either one over would destroy work silently and return no error. A
corrected export now fixes the time, the title and the location, and
leaves who said yes alone. `uid` is held back with them: it is what the
two were matched on, so it is already equal.

The scan returns uid -> id rather than a set of UIDs, since updating
needs something to address, and creates and updates now share one
`maxObjectsInSet` budget the way contacts' `writeCards` does -- 300 new
and 300 changed batched separately would be two calls of 300, neither
over a ceiling of 500 and both refused. Counts become created/updated,
reported as the contacts import reports them.

Still no scheduling messages, on an update as much as on a create. That
is a real cost -- an event a re-import moves is moved here and nowhere
else -- and it is the lesser one: an import is not the place to start
mailing a room full of people who never asked for it.

Driven against the mock end to end: a second file with the same UID
updated the event in place, took the file's title, start and location,
and left an accepted RSVP and a per-occurrence override untouched even
though the file carried participants of its own.
2026-09-07 12:56:36 -07:00
Coffey Labs 7825097333 Merge pull request #300 from Coffey-Labs/fix-mobile-dialog-behind-drawer
Raise dialogs and the composer over the mobile drawer
2026-09-06 21:42:30 -07:00
jcoffey-dev cd6dff5346 Raise dialogs and the composer over the mobile drawer
On a phone the folder list is the drawer, so it is also where a new folder
is started -- and the New folder dialog was stacked at 900 against the
drawer's 950, so it opened behind the folder list with only a sliver
showing past the drawer's right edge. Unusable: the name field and the
Cancel button were both underneath.

The same trigger, the same fault, one layer down: Compose in the drawer
opens a full-screen composer, and at 800 that came up behind the drawer
too.

A modal has to outrank the navigation that raised it. The dialog backdrop
goes to 960 and the composer dock to 955, which keeps every relationship
those two already had -- a dialog still clears a composer, popovers,
tooltips and toasts still clear both -- and adds the one that was missing.
Desktop is untouched: the drawer's z-index only exists below 768px, and
nothing sat between 800 and 960 anywhere else.

The stack is now written down beside `.dialog-backdrop`, and guarded by a
test on the stylesheet rather than a component test: jsdom has no paint
order, so nothing in a rendered tree can tell that a dialog is behind the
drawer that opened it.

No user-visible strings change; the nine catalogues are untouched, and the
fallback count holds at 16 in each.
2026-09-06 21:39:45 -07:00
Coffey Labs 0e05bee69a Merge pull request #299 from Coffey-Labs/stalwart-version-catchup
Say 0.16.21 where the docs still said 0.16.20
2026-09-06 17:13:09 -07:00
jcoffey-dev dc676acf52 Say 0.16.21 where the docs still said 0.16.20
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.
2026-09-06 17:10:31 -07:00
Coffey Labs 1a955d64df Merge pull request #298 from Coffey-Labs/docs-catchup
Catch the README and FEATURES up with the themes and 0.16.21
2026-09-06 17:04:58 -07:00
jcoffey-dev 575f634f9c Catch the README and FEATURES up with the themes and 0.16.21
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.
2026-09-06 17:01:59 -07:00
Coffey Labs fdfb83b254 Merge pull request #297 from Coffey-Labs/i18n-fill-gaps
Translate the eight strings that were still falling back
2026-09-06 16:56:45 -07:00
Coffey Labs 17a24fe880 Merge pull request #296 from Coffey-Labs/palettes-twelve
Six more palettes, taking the picker to twelve
2026-09-06 16:56:29 -07:00
jcoffey-dev 9e7723ca66 Translate the eight strings that were still falling back
Every catalogue was at 1,255 of 1,279 with 24 strings rendering English.
Eight of those are real UI text and are now translated in all nine
languages: the five sort options that had no entry while their opposites
did (Read first beside Unread first, Unstarred first beside Starred
first, Smallest first beside Largest first, and the two alphabetical
directions), and the three sentences behind the link and external-sender
warnings. Each follows the phrasing its own catalogue already used for
the sibling it sits next to.

The remaining sixteen are left in English deliberately, because
translating them would be wrong: product and project names, the sample
addresses in placeholder text, bare URL prefixes, the ellipsis used as a
masked value, and two mail header names.

Per locale: 1,263 of 1,279, up from 1,255.

**The stale list is not touched, and should not be cleaned blindly.** The
checker reports 41 keys as translated-but-never-looked-up, and some of
them are live. "Classic" is the clearest: the palette picker renders it
through translate(p.name) from a constant, so the extractor sees no
literal, while the German "Klassisch" it would delete is the exact fix
issue #247 asked for. "Add star" and "Remove star" are the same shape,
reached through a ternary in a JSX label. Teaching the extractor those
two call sites is the prerequisite for trusting that list.
2026-09-06 16:35:32 -07:00
jcoffey-dev befe1dbf53 Six more palettes, taking the picker to twelve
Catppuccin, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the
light and dark variant its own project publishes: Latte and Mocha, Lotus
and Wave, and so on. Values were fetched from each project's own repository
and recorded in .palette-sources/palettes-upstream.md, with the two tiers
no project publishes marked derived rather than passed off as upstream.

Four candidates were rejected rather than adapted. Nord and Synthwave '84
publish no light variant, and inventing one is not porting a theme.
Monokai is proprietary and its licence forbids redistribution. Material
Theme has become a commercial product whose repository no longer publishes
a palette at all.

Body text is now lifted for contrast like every other text tone rather
than exempted and merely checked. Most of these palettes target their own
~4.5:1 for body text where ihasmail asks 7:1, so the old rule would have
rejected five of the six on a bar their designers never aimed at. Nudging
the published colour along its own hue is what the script already does for
muted text, links and accents, and every shift is printed in the generated
CSS: Solarized light moves 4.13 to 7.07, Primer needed nothing at all.

Primer is named for the design system, not for GitHub. The colour values
are MIT; the name and the logo are trademarks, and NOTICE says plainly
that nothing here is endorsed.

The picker grid already wrapped on its own, so twelve cards needed no
layout change.
2026-09-06 16:31:38 -07:00
Coffey Labs a875274a8e Merge pull request #295 from Coffey-Labs/palette-credit-generic
Stop naming every palette in the credit line
2026-09-06 16:20:39 -07:00
jcoffey-dev 276ecfccff Stop naming every palette in the credit line
The hint under the theme picker listed the third-party palettes by name.
That sentence is translated into nine languages, so every palette added
meant rewriting it, retranslating it nine times, and leaving the previous
version behind as a stale key nothing looks up.

It now describes the rule instead of enumerating the cases: a palette
named after another project is that project's work, used under its own
licence. True of the four here, true of the next one, and true without
saying "MIT" for a palette that might not be. The names are already in
Settings beside each swatch and in NOTICE with their copyright lines,
which is where a credit belongs.

Swapped rather than added in all nine catalogues, so the old key is gone
rather than left stale: 1,255 of 1,279 translated per locale, unchanged,
and the 41 pre-existing stale keys are neither added to nor cleaned up
here.
2026-09-06 16:14:51 -07:00
Coffey Labs a35f360952 Merge pull request #294 from Coffey-Labs/theme-styled-mail
Force the theme onto mail that styles itself
2026-09-06 16:04:55 -07:00
jcoffey-dev 2464c9655f Let the theme be forced onto mail that styles itself
Appearance gained "Apply the theme to messages too" some time ago, and it
themes an HTML message only when the message brings no colours of its own.
That predicate is the right default and it almost never passes: one
`color:#FFFFFF` on one button label opts a whole message out, so in real
mail — receipts, shipping notices, anything from a template — the switch
did nothing at all and the reader kept a bright white card on a dark UI.

A second switch, off by default and only meaningful with the first on,
forces the palette over the sender's colours. It cannot be done perfectly,
which is why it is a separate, explicit choice: the same bargain a
dark-reader extension makes.

What it does is tell two kinds of colour apart. A *sheet* the design sits
on — the white 600px wrapper — is neutralised, and a *painted surface* —
a call to action, a footer banner — is kept whole so its label stays
legible on it. Relative luminance decides, at 0.5: white wrappers sit at
1.0, a blue button near 0.09. Only the painted ones are marked, with
data-ihm-keep, and one rule in EMAIL_BASE_CSS neutralises everything else.

Nothing the sender wrote is removed, so the switch is reversible, colours
arriving from a <style> block are covered as well as inline ones, and
print still pins the tokens to ink on white.

The mock grew the message this is about: an outer wrapper on
bgcolor="#ffffff", a <style> block, a coloured button, a grey footer.
Without one, neither the bug nor the fix could be seen.

Verified in a browser against the mock: with only the first switch on the
card is still white; with both, the wrapper computes to transparent, body
text follows the theme, and the button keeps white-on-blue. Two surfaces
marked, which are the two the message paints.

Closes #290
2026-09-06 15:58:25 -07:00
Coffey Labs c66308e4bb Merge pull request #293 from Coffey-Labs/mock-0-16-21
Follow Stalwart 0.16.21 in the mock
2026-09-06 15:48:06 -07:00
jcoffey-dev 6432e11beb Follow Stalwart 0.16.21 in the mock
Four changes, each confirmed against a real 0.16.21 rather than read from
the changelog.

Synthetic recurrence ids are now built from an occurrence's recurrenceId
instead of its position, so they survive a write. This reverses a hazard
the mock reproduced on purpose: up to 0.16.20 writing one override
renumbered the series and a held id silently named a different date. A
five-week series was expanded live, its third occurrence retitled through
its synthetic id, and all five original ids re-read; every one still
resolved to its own date. The test that pinned the instability now pins
the stability, with two more around it.

Calendar/get and AddressBook/get return every property when properties is
omitted or null, shareWith included. Mailbox/get on the same server still
omits it, so that stripping stays and now applies to mailboxes alone.

EventSource ping events advertise the interval in seconds, not
milliseconds. The mock parses the parameter it used to ignore: a 30 s
floor, larger values honoured, 0 disables pings, a non-numeric value is a
400. The first ping now arrives one interval in rather than on connect,
which is what the server does.

CalendarEvent/set rejects create, update and destroy with forbidden when
the request asks for scheduling messages and the account may not send
them. MOCK_NO_SCHEDULING_SEND=1 develops against that account.
2026-09-06 15:43:12 -07:00
Coffey Labs db7b103a08 Merge pull request #292 from Coffey-Labs/push-subscribe
Push by subscription, and a latency fix for the compressor
2026-09-06 14:56:48 -07:00
jcoffey-dev 2c47c0851c Push by subscription: hold no upstream connection per tab
A signed-in tab held two sockets: the browser's, and one from ihasmail to
Stalwart carrying that tab's push stream. The upstream one was most of what a
tab cost, and the only reason Stalwart's connection limit applied to ihasmail
at all.

RFC 8620 section 7.2 defines the other push transport: a PushSubscription,
where the server POSTs StateChange objects to a URL the client registers.
Stalwart 0.16.20 implements it. ihasmail now registers one subscription per
account at sign-in, and when Stalwart POSTs a change, fans it out to that
account's open tabs over the browser-facing streams it already holds. A tab
opens on the relay as before and is moved to fan-out the moment its account
verifies -- the upstream request is ended, the browser stream is untouched,
and nothing keeps a reference to what was torn down. After that there is no
upstream connection at all. The shapes are the RFC's; nothing here is taken
from any other client.

Measured at a 256 MiB cap over a private plain-HTTP route, against a real
Stalwart with 6,144 accounts verifying during the ramp and no failures:

                                 tabs   client   Stalwart   system  KiB/tab
  raw relay (before)            5,000     48.2       46.4     94.6
  push by subscription          6,144     33.3        4.8     38.0
  a direct-to-server client   12,389      4.8       53.8     58.6

Descriptors per tab: one, the browser's. Stalwart pays 4.8 KiB per tab and
holds no connection for it, so its per-listener connection limit no longer
applies to ihasmail. What remains per tab on the client is Node's cost for a
held HTTP/1.1 connection.

PUSH_URL is the https origin Stalwart can reach ihasmail at. The RFC requires
https and Stalwart enforces it, so Stalwart must trust that certificate: a
public TLS front already does; a private segment needs an internal CA in
Stalwart's trust store. An account whose subscription cannot be verified
stays on the relay, so nothing breaks -- only the saving needs the
certificate. PUSH_MODE=relay disables the subscription path entirely.

/api/push/:token accepts only a JSON body under 64 KiB for a known 32-byte
token, answers 200 or 404, and echoes nothing. /api/health reports how many
accounts are verified, pending or failed and how many tabs are on each path.
2026-09-06 13:30:34 -07:00
jcoffey-dev f569f2cc7a Skip the compressor for clients that offer no encoding
Listing latency at one user went from 1.95 ms on the previous release to
3.25 ms on main, and a bisect put the whole of it on the compression commit.
Not on compressing: the harness never sent Accept-Encoding, so nothing was
ever gzipped. Hono's middleware still inspects every compressible response it
declines and sets Vary on it, and setting a header on a streamed passthrough
rebuilds the Response off its fast path -- about 1.2 ms per JMAP call, on a
request that had asked for nothing.

The middleware now runs only when the request names gzip or deflate. Measured
at one user against the same Stalwart:

  compressor touches but declines, no Accept-Encoding   3.25 ms
  skipped entirely, no Accept-Encoding                  2.02 ms
  compressor applied, Accept-Encoding: gzip             2.27 ms
  previous release, either                              1.95 ms

Applying gzip to a JMAP response costs about a quarter of a millisecond and
saves three to five times the bytes on every listing and body, so JMAP
responses stay compressed by default; COMPRESS_JMAP=0 turns that off for a
deployment that would rather not.

The raw push relay is also made safe to tear down from outside -- the
browser stream keeps its headers and is not ended when the upstream request
goes -- which the next change relies on.
2026-09-06 13:22:03 -07:00
Coffey Labs 3fd0d0cfa6 Merge pull request #289 from Coffey-Labs/footprint
Smaller footprint: three times the tabs, a third of the image, a budget per session
2026-09-06 00:46:25 -07:00
jcoffey-dev ed93fefb9b Give each session a budget on the data path
Only sign-in and the account endpoints were rate limited. JMAP, blob
downloads and the image and calendar proxies had no budget at all, and the
proxy is one Node process that saturates a core at roughly 2,000 operations a
second -- measured at 110% CPU under 150 concurrent users. One signed-in
account looping requests could slow every other user on the instance.

Each session now gets API_RATE_LIMIT requests a minute on those routes, 1,200
by default: twenty a second sustained, well above what a busy tab does and an
order of magnitude below where one tab starts to hurt the rest. Over budget
returns 429 with Retry-After. Sign-in keeps its own, separate limiter.

Checked in situ: one session driven flat out was cut off after exactly 1,200
requests, and with API_RATE_LIMIT=0 throughput at 50 users is unchanged.
2026-09-06 00:42:49 -07:00
jcoffey-dev 6098ffb8e5 Ship the runtime image without the build tree
639 MB unpacked and 119 MB compressed, against 239 MB and 59 MB now. Two
causes, both in the runtime stage.

The build stage's node_modules was copied across whole: 132 MB of vite,
TypeScript, esbuild, jsdom and React that the server never loads, since it
needs hono and its Node adapter and nothing else -- about 4 MB. The runtime
stage now installs the server workspace's production dependencies on its own.

Then `chown -R node:node /data /app` rewrote every one of those files, which
on overlayfs copies the whole tree into a second layer of the same size. Only
/data is written to at runtime; /app stays root-owned and read-only to the
process, which is what an immutable container wants anyway.

The base image's npm, npx, yarn and corepack are removed from the runtime
stage as well. The server is started with `node` directly and never calls
them; anyone who gains code execution should not find a package manager
waiting.

Checked that the image starts --read-only, serves the gzipped bundle, signs
in against Stalwart, holds a push stream, and that `hono` loads from the
3.1 MB that remains.
2026-09-06 00:42:19 -07:00
jcoffey-dev 01f721d8d1 Cut what a signed-in tab costs by two thirds
Two changes on the push path, both measured against a real Stalwart 0.16.20
with the container capped at 256 MiB and tabs added in steps of 200 until the
kernel killed it:

                                  tabs held   per tab   of which native
  before                              1,665   133 KiB          81 KiB
  pin upstream calls to STALWART_URL  3,400    58 KiB           8 KiB
  + raw push relay                    4,979    37 KiB          10 KiB

Stalwart advertises absolute https URLs in every session, and the proxy
followed them -- so even with STALWART_URL naming a private plain-HTTP hop on
the same Docker network, every held push stream went out through TLS. That leg
is about 80 KiB of OpenSSL state per tab: native memory Node cannot see, which
is why neither the heap ceiling nor the stream buffer size ever moved the
number. absoluteUpstream() now keeps the path and query from the advertised
URL and the scheme, host and port from the configured one. A setup that must
reach Stalwart at an origin other than the one it was given sets
STALWART_FOLLOW_ADVERTISED_URLS=1.

With the transport out of the way, the fetch()-based relay was the next cost:
an undici Response, a web ReadableStream, a reader and Hono's stream bridge
held alive per tab, about 44 KiB of heap for a session that otherwise costs
4 KiB. relayPushRaw() pipes the upstream socket into the Node response and
tells the adapter the response is already sent. RAW_PUSH_RELAY=0 restores the
fetch path for comparison.

JMAP throughput is unchanged (2,383/s against 2,484/s at 50 users, inside
run-to-run noise); the relay does not touch that path. Verified that a push
stream through the raw relay delivers a StateChange while mail is written.

The install page's advice to set --max-old-space-size was measured in the same
runs and made no difference at all -- 3,400 tabs with it and without -- and
is withdrawn in the docs alongside this change.
2026-09-06 00:42:19 -07:00
Coffey Labs 5356e603fe Compress our own responses (#288)
* Compress our own responses

The bundle went out uncompressed unless a proxy in front did the work: 933 KB
on the wire where 311 KB does, on every first load. Both example proxy configs
compress, but that only helps deployments that copied them, and the default
should not depend on reading the examples.

Hono's middleware, with the proxy routes held back. `/api/blob`, `/api/image`,
`/api/ics` and `/api/upload` forward somebody else's bytes under a
content-length copied from upstream, and issue #76 was a silent truncation
caused by exactly that header disagreeing with its body. Re-encoding them
would be safe in principle -- the length is dropped and the response goes out
chunked -- but they carry attachments and images that are already compressed,
so there is nothing to win and a scar to respect.

`/api/events` is listed with them even though Hono already skips
text/event-stream by content type, so that changing the push route's type
cannot quietly start buffering the stream.

`/api/health` is excluded for the opposite reason: at 47 bytes gzip made it 73.
Hono's size threshold cannot catch that on its own, because it only applies
when a response carries a content-length and `c.json()` does not set one. The
other JSON routes stay compressed -- a JMAP response has just as unknown a
length and can run to hundreds of kilobytes.

Verified against the built image: assets come back gzipped with Vary set,
662 KB to 209 KB; /api/events still returns text/event-stream with no
content-encoding and delivered a StateChange while mail was being written;
health is 47 bytes either way. No user-visible strings, so no catalogue work.

* Word the comment for either side compressing

The app compresses its own responses as of the follow-on change, so a note
saying the bundle ships uncompressed would be wrong as soon as that lands.
nginx passes through what the upstream already encoded rather than re-encoding
it -- verified single-encoded with both layers active -- so the directives are
correct either way and the comment now says so without asserting which side
does the work.

* Test compression against a fixture, not the web build

The compression tests asked for `/` and asserted a gzipped 200. That passes
locally, where `web/dist` is lying around from an earlier build, and fails in
CI, which runs `npm test` before `npm run build`: with no bundle the shell
route serves the "web build not found" fallback, which is short, plain text and
correctly uncompressed. The failure read as compression being broken when the
tests were simply depending on a build step that had not run.

They now build their own static root in a temp directory and point STATIC_DIR
at it, in a separate file so the environment is set before the app module is
imported. Checked by moving web/dist aside and running the suite the way CI
does.
2026-09-05 23:41:29 -07:00
Coffey Labs fafeee481e Merge pull request #287 from Coffey-Labs/nginx-example-compression
Compress the bundle in the nginx example
2026-09-05 23:24:26 -07:00
jcoffey-dev a618f3fca6 Compress the bundle in the nginx example
The Caddy example has `encode zstd gzip`; the nginx one had nothing, so a
deployment following it shipped every asset uncompressed. Measured against the
built app that is 915 KB on the wire where 307 KB would do -- the difference
falls entirely on first load, and silently, since nothing about it is visible
without inspecting response headers.

`text/javascript` is listed explicitly. The server sends scripts with that
type rather than `application/javascript`, so a conventional gzip_types list
compresses the stylesheet and leaves the 647 KB script alone -- which is what
happened on the first attempt at this change.

text/event-stream is deliberately not listed. Compressing or buffering the
push stream would break it; proxy_buffering is already off below for the same
reason. Verified that /api/events still delivers a StateChange event through
the proxy, as plain text, while assets come back gzipped with Vary set.
2026-09-05 22:44:25 -07:00
Coffey Labs 7d6dfe4581 Merge pull request #286 from Coffey-Labs/smime-signature-verification
Check S/MIME signatures, and remember who signed
2026-09-05 01:46:31 -07:00
jcoffey-dev c84f190f76 Check S/MIME signatures, and remember who signed
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.
2026-09-05 01:42:51 -07:00
Coffey Labs 7aa2e374d4 Merge pull request #285 from Coffey-Labs/revive-public-key-management
Write down what Stalwart's x:PublicKey registry does, and withdraw the key manager
2026-09-05 01:16:06 -07:00
jcoffey-dev 45c8929697 Withdraw the key manager, and keep what probing it established
A Settings section for public keys is furniture, not a feature. Nothing
in ihasmail signs, encrypts, decrypts or verifies with a key, so the
page could only ever tell the reader in its own footnote that adding one
does nothing. It is withdrawn on that reasoning -- the same reasoning
that closed PR #67, reached again with the code in front of us.

So this reverts every user-visible part of it: the section, the lib, the
mock handlers, the component and the 261 catalogue strings. Nothing in
web/ or server/ differs from main now.

What stays is the part that was expensive and is true regardless. The
x:PublicKey registry was probed against a live 0.16.20 on 2026-09-05,
and the findings are now in KNOWN-ISSUES rather than in a closed pull
request -- which is where they sat for the nine days between #67 and
this branch, and why the work was done twice. Consolidated into one
entry, framed as what Stalwart does rather than what ihasmail offers:

  - an ordinary user may read and write their own keys, whatever the
    permissions table says
  - the registry takes S/MIME certificates as well as OpenPGP keys, and
    parses both -- confirmed with a real self-signed X.509 certificate,
    and a malformed one gets its own BER decoding error
  - a key can parse and still be refused, with different words. A
    sign-and-certify key -- what `gpg --quick-generate-key` makes --
    gets "Could not find any suitable keys", which is not a paste error
    and must not be shown as one
  - emailAddresses comes back as {} when empty, an object where a list
    property should be an array. It type-checks, then throws in join()
  - a create answers with the id alone; patching `key` is allowed
  - expiresAt is the registry's field and is not derived from the key

ROADMAP now says plainly that key management has been built and
withdrawn twice, that the registry is not the obstacle, and that
verifying a signature -- which needs only public keys -- is the shortest
route to a key being worth having. Encryption at rest moves from "not
offered yet" to refused: it is a one-way door, since turning it off does
not decrypt what is already there, and that is not a switch to hand an
ordinary user however easy it would be to add.
2026-09-05 01:11:25 -07:00
jcoffey-dev 6a467d9bc4 Check the S/MIME half against a real server, instead of assuming it
The section offered "an OpenPGP public key or an S/MIME certificate" and
only the first half had ever been tried. Every probe behind it used
OpenPGP keys, and every message the registry returns names OpenPGP --
including for input that is not OpenPGP at all -- so the server reads as
though OpenPGP were the only format it knows. Shipping the claim on that
evidence would have been a guess dressed as a feature, which is the one
thing this section is written not to do.

It holds. Confirmed live on 0.16.20 (2026-09-05) with a self-signed
X.509 certificate carrying emailProtection and an email: SAN:
registered, read back, destroyed. And Stalwart parses it as seriously as
it parses OpenPGP -- a malformed certificate is refused by a decoder of
its own, "Failed to decode X509 certificate: BER decoding error:
Expected Tag { class: Universal, value: 16 } tag…", which is a third
rejection wording and the reason the S/MIME half is real rather than
decorative. The mock now returns it for a certificate, so the branch
exists somewhere a client can meet it.

One thing found on the way: expiresAt is the registry's field and is not
derived from the key. A certificate valid for a year registers with
expiresAt null, so the card says "No expiry set" about a credential that
does expire. Left as it is, deliberately: reading the real date means
parsing the certificate, which is the second opinion this section
refuses to offer, and a date extracted here would disagree with the
server's own field the moment the two ever differed. What the row
reports is what the registry holds, and KNOWN-ISSUES says so.
2026-09-05 01:03:52 -07:00
jcoffey-dev e93d42d27e Manage public keys, over Stalwart's x:PublicKey registry
A new Settings section, next to Identities & signatures: list, add,
rename and remove the OpenPGP public keys and S/MIME certificates
published on this account. Only public material -- no private key is
stored, requested or sent by any of this.

This is PR #67 revived. That branch was built against 0.16.19, closed
unmerged on 2026-08-26, and shares no ancestry with main after the email
scrub, so it is ported rather than rebased: the four files it added are
carried over, the three it edited are applied by hand, and everything it
claimed was re-probed against the live 0.16.20 on 2026-09-05. The i18n
work is new -- nine catalogues landed on 2026-08-31, after that branch
was written.

What the re-probe confirmed, unchanged from 0.16.19:

  - An ordinary user may read *and* write their own keys, though the
    permissions table lists every sysPublicKey* permission as
    administrative. get and query both answered for a normal account,
    and a malformed create came back invalidProperties naming `key`
    rather than forbidden -- a rejection of the key, not of the person.

  - The server parses the key and says precisely what is wrong. So
    ihasmail does not validate key material; the server's message is
    shown verbatim, as password-policy rejections already are.

  - urn:stalwart:jmap is still absent from the session's top-level
    capabilities and present per-account, so the check that reads all
    three places is still the one that works.

What it added, none of which was known before:

  - A key can parse perfectly and still be refused, with different
    words: a sign-and-certify key with no encryption subkey -- what
    `gpg --quick-generate-key` produces -- gets "Could not find any
    suitable keys in OpenPGP public key". That is the rejection somebody
    exporting from GnuPG will actually meet, and it is not a paste
    error, so collapsing both to "invalid key" would send them back to
    the clipboard for a problem that is in the key.

  - emailAddresses comes back as {} when empty -- an object where a JMAP
    list property should be an array. It type-checks, then throws in
    join() while the list renders. normalize() checked the shape
    already; there is now a test saying why, and the mock answers {} the
    same way, because one that helpfully returned [] would let that
    crash ship.

  - A create answers with the id alone, no createdAt, so adding a key
    reloads rather than believing the response.

  - destroy works and leaves the registry empty. PR #67 shipped that
    path untested -- its live probe was refused before anything was
    created, so there was nothing to destroy.

  - Patching `key` is allowed by the server. The mock still refuses it,
    now deliberately rather than for want of evidence: ihasmail replaces
    a key by adding one and removing the old, which keeps createdAt
    meaning what it says.

x:EncryptionAtRest still does not exist on 0.16.20 -- asking for it is
an unknownMethod. encryptionAtRest is a field on x:AccountSettings, and
its value is a typed object ({"@type":"Disabled"}) rather than the bare
string ROADMAP described. Nothing here writes it.

An empty description is now sent as empty rather than filled in with
"Key". The description is stored on the server, so a default invented in
the client would be whichever language the adder happened to be using;
the list labels a blank one at render time instead.

Verified in a browser against the mock, not only in tests: both
rejections reach the toast in the server's own words with the form still
filled in, a good key renders its card, the kind is labelled from the
armour header, renaming persists, removing asks first and empties the
list, and the whole section reads correctly in German.
2026-09-05 00:56:31 -07:00
Coffey Labs e9349863e8 Merge pull request #284 from Coffey-Labs/add-funding-config
Add GitHub Sponsors funding config
2026-09-05 00:23:24 -07:00
jcoffey-dev 3a74f0a715 Add GitHub Sponsors funding config
Point the repository Sponsor button at the live LINUXexpert-org
GitHub Sponsors listing.
2026-09-05 00:20:26 -07:00
Coffey Labs 1f9c17ad18 Merge pull request #283 from Coffey-Labs/roadmap-smime-reasoning
Say why S/MIME rather than OpenPGP, and why neither is urgent
2026-09-04 13:31:13 -07:00
jcoffey-dev 0df62e6b2f Say why S/MIME rather than OpenPGP, and why neither is urgent
The entry recorded what the probing established and what the design
caveat is, and said nothing about why this is the encryption worth
building or why it sits on this page rather than in the tracker. Somebody
reading it -- including me in six months -- could reasonably conclude the
choice was arbitrary.

End-to-end encrypted mail never reached the mainstream, and the reasons
are structural rather than a tooling problem: everyone in a thread has to
take part, key discovery was never solved and the keyservers got
weaponised, there is no forward secrecy, the metadata stays in the clear,
a lost key loses the mail, and it breaks search and spam filtering. EFAIL
showed the clients were exploitable too. The privacy win that actually
landed was STARTTLS, MTA-STS and DANE, which needed nothing from users.

S/MIME wins between the two because it is more deployed where software
gets paid for -- native in Outlook and Apple Mail, routine in defence,
healthcare, finance and government -- since a CA issues and revokes
certificates an IT department can administer, which the web of trust
never managed.

The last paragraph is the one that will matter in practice: a self-hosted
webmail for Stalwart draws the densest concentration of PGP users left,
so this will be asked for far more often than it would be used. That is
the argument for keeping it here and honest rather than building it on
the strength of the requests.

Docs only. No strings added, no catalogues touched.
2026-09-04 13:28:19 -07:00
Coffey Labs eca8468d84 Merge pull request #282 from Coffey-Labs/docs-release-cadence
Say near the top that latest lags main, and by how long
2026-09-04 13:14:27 -07:00
jcoffey-dev 429c232e0c Say near the top that latest lags main, and by how long
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.
2026-09-04 13:11:44 -07:00
Coffey Labs 53a44d7d18 Merge pull request #281 from Coffey-Labs/docs-contributing-branch-rules
Say in CONTRIBUTING that main is protected, and that strings need nine catalogues
2026-09-04 12:54:59 -07:00
jcoffey-dev fa22d30347 Say in CONTRIBUTING that main is protected, and that strings need nine catalogues
Two things a contributor could only find out by tripping over them.

`main` now carries a ruleset: a pull request with a green build check, no
force-push, no deletion, and deliberately no required approval -- which
would lock a solo maintainer out of their own repository rather than
protect anything.

And a new user-visible string is work in nine catalogues. A missing key
renders its English source rather than failing, so the omission is
invisible from here and obvious to anyone reading that language. The
plural-key trap is in CLAUDE.md rather than repeated here.

Docs only. No strings added, no catalogues touched.
2026-09-04 12:52:17 -07:00
Coffey Labs fcbd8f6449 Merge pull request #280 from Coffey-Labs/claude-md-i18n-gotcha
Write down the plural-key gotcha, and how to tell it happened
2026-09-04 12:31:09 -07:00
jcoffey-dev 310dc85b62 Write down the plural-key gotcha, and how to tell it happened
The catalogue key for a plural is the `other` form -- `plural()` looks the
entry up by `forms.other` -- and keying it on the `one` form type-checks,
builds, passes every test, and falls back to English in all nine
languages. Nothing errors. It cost a round trip on #278 and would cost
the next one the same.

The part worth writing down is not the rule but the signal, because there
is only one: the "falling back to English" count from
i18n-catalog-check. The percentage is no use for this -- adding keys
moves the denominator, so it holds steady at 98% whether the new strings
are translated or not.

Also here: that a change touching user-visible strings is work in nine
catalogues and should be reported as such, including when the answer is
none; and that store tests do not exercise the component, with the
shift-click range bug from #278 as the standing example -- measured
inside a setState updater, which React runs after the anchor ref has
moved, so it passed every store assertion and failed the moment the built
app was driven.

No CLAUDE.md existed before this.
2026-09-04 12:28:19 -07:00
Coffey Labs 0c9a15a691 Merge pull request #278 from Coffey-Labs/contacts-bulk-delete
Select contacts, and empty an address book
2026-09-04 12:19:18 -07:00
jcoffey-dev cee107d948 Select contacts, and empty an address book
Raised on #174 as the other half of a migration -- import, notice
something is wrong, empty the book, correct the export, import again --
and tracked as #277.

The gap turned out to be wider than the ask. Contacts had no multi-select
at all: the only delete in the module was the cross on a single card's
pane, one card and one confirmation at a time. `destroyCards` has taken a
list and batched it against maxObjectsInSet since #218, and nothing in
the UI ever handed it more than one id. So "empty this address book" was
missing, and so was "delete these fourteen".

The list now has checkboxes, on hover the way the message list's are, and
always on a touchscreen where there is no hover to reveal them.
Shift-click takes the run between two rows. The search box gives way to a
selection bar rather than sitting beside it, because what the count
promises is what the search left on screen. A selection is cleared when
the book being shown changes, since carrying it across would leave a
count describing rows that are no longer there and a Delete aimed at
them.

Emptying a book is in the book's own menu, beside the import and export
that moved there in #226, and separate from Delete, which takes the book
with it. A default book cannot be deleted and can perfectly well be
emptied, which is most of the reason it is its own entry.

The part that is not a deletion, and the reason this is not one destroy
over everything in the book: a card filed in two books belongs to both,
and `ContactCard/set destroy` takes it away from both at once. Emptying
one book must not empty another, so a card with a second home is patched
out of this one and left alone. That is reported separately afterwards,
because it would otherwise look like contacts that refused to go.

`destroyCards` now answers with what the server confirmed rather than
throwing on the first refusal. A refusal that took half a selection with
it still deleted the other half, and an error saying only that it failed
sends somebody looking for contacts that are already gone. Both callers
report the count and the reason apart.

Emptying a shared book is deliberately not offered: the cards live in the
owner's account and this client has no path to write there.

One bug found by driving the built app rather than by any test, and worth
recording because of where it hid. The range a shift-click covers was
measured inside the `setPicked` updater -- which React runs when it gets
round to rendering, by which time the anchor ref has already been moved
to the row that *ended* the range. Every shift-click selected exactly one
row, and every store assertion still passed, because nothing was wrong
below the component. The anchor is read before the updater now, and the
contacts view has its first component tests: ten of them, six of which
fail if the measurement moves back inside.

Twelve new strings, in all nine catalogues, so nothing new falls back to
English.
2026-09-04 12:08:53 -07:00
Coffey Labs f201b09e90 Merge pull request #276 from Coffey-Labs/reply-to-my-own-message
Ask the folder, not just the identity list, whether a message was mine
2026-09-04 08:13:37 -07:00
jcoffey-dev 029f079094 Ask the folder, not just the identity list, whether a message was mine
Replying to a thread whose last message I sent addressed the reply to me:
Reply put my own address in To, and Reply all put me in To with everyone
I had actually written to demoted to Cc. Following up on your own last
message is an ordinary thing to do, and this made it useless.

There was already a guard for exactly this, and the guard was sound. What
it rested on was not. It asked whether an address was in the identity
list, and that question has a wrong answer in more situations than it has
a right one:

- the list is empty until identities load;
- an alias or a shared mailbox is not in it at all;
- it compared lowercased strings with `includes` where the rest of the
  codebase uses `sameAddress`, so an identity address stored with
  whitespace was enough to break it;
- the check ran on the address the reply was about to go to rather than
  on the sender, so a message of mine carrying a Reply-To skipped it
  entirely and my reply went to my own desk;
- and the Reply all branch never filtered my own address out of To, though
  the Reply branch did.

Every one of those failed silently, which is why five of them accumulated.

So the folder is asked first: a message in Sent is mine whatever address
it went out as, and `mailboxIds` is already fetched in LIST_PROPS with
roleId("sent") on the mail store, so this costs no request. The identity
list stays as a second opinion, now compared with `sameAddress`, and the
whole test keys off the sender rather than off the computed recipient.

Two cases remain unanswerable and are commented rather than papered over:
a message from an unlisted alias that is not in Sent either, and any
message at all when identities failed to load and it is not in Sent.
Neither signal exists. Both are far narrower than what was broken.

Reply addressing had no tests at all, which is how a guard this
load-bearing came to be wrong five ways at once. Fifteen now, seven of
which fail against the old code.
2026-09-04 08:10:28 -07:00
Coffey Labs 4d23cef511 Merge pull request #274 from Coffey-Labs/ldif-dedupe-on-dn
Match an LDIF re-import on the entry's dn
2026-09-04 07:54:42 -07:00
jcoffey-dev b4248a6661 Match an LDIF re-import on the entry's dn
Reported again by the submitter's colleague at LINET after #223 was
closed: duplicate checking was implemented for vCard and never for LDIF,
so re-importing an address book still leaves a second copy of everything.
That was deliberate at the time -- the matching key was an open question
I did not want to answer alone -- but the answer had already been given
on #174 and I closed the issue without acting on it.

The answer, in the submitter's words: an attribute that *can* change is
fine, because it will not have changed between two imports minutes apart.
An import is not a sync. That makes the `dn` usable -- it is the only
identity the file carries, and Mozilla's schema defines no UID -- and it
needs no guessing at all, unlike the name-plus-email fallback I had been
weighing.

So `uidFromDn` derives a namespaced, stable uid from the distinguished
name, normalised for the case and spacing two exports of one directory
differ in. A card the book already holds under that uid is updated rather
than duplicated, merged the way the vCard import merges: what the file
carries wins, what it does not mention is left alone. Reported as created
and updated, which is the pair that was asked for.

Three things worth knowing:

Matching is per address book, so two customer directories that each hold
a `cn=John Smith` stay two people as long as they are filed separately.
Imported into one book they would merge, which is the one way this can be
wrong and the reason the escape hatch is worth naming.

The look-alike count stays, and now means something narrower: entries
that `dn` matching could not catch -- one whose `dn` moved between
exports, and anything imported before there was a `dn` to match on. Those
are still only counted, never merged.

A file holding two entries under one `dn` is malformed, since a directory
cannot, and now becomes one card instead of two sharing an identity.

FEATURES gains the re-import behaviour for both formats; it documented
neither.
2026-09-04 07:50:49 -07:00
Coffey Labs 1f8c12e29e Write the S/MIME position down (#273)
It was backlogged in conversation on 2026-08-27 and recorded nowhere in
the repository -- not in ROADMAP, FEATURES, KNOWN-ISSUES, the README or
the docs. That is the state a plan is in just before it is forgotten,
and it is also the state that lets the same probing get done twice.

The entry carries what the earlier work established against a live
0.16.19, including the two findings that contradict the documentation:
encryptionAtRest is a field on x:AccountSettings rather than an object of
its own, and ordinary users can write their own x:PublicKey entries
despite the permissions table listing every sysPublicKey permission as
admin-only. Dated, and marked not re-run since the 0.16.20 upgrade, the
way KNOWN-ISSUES dates its entries.

PR #67 is named as the starting point: a working public-key manager,
closed unmerged, none of which is in the tree today.

The caveat that matters most is last, because it is the one a user
cannot undo: turning encryption-at-rest off does not decrypt what is
already encrypted.
2026-09-03 23:25:44 -07:00
Coffey Labs 17d98748c4 Merge pull request #272 from Coffey-Labs/i18n-missing-plurals
Add seven plural forms no catalogue ever had
2026-09-03 14:47:00 -07:00
jcoffey-dev 1070ee13bc Add seven plural forms no catalogue ever had
Found by widening the coverage check to plural() forms in every file rather
than the two being worked on. Seven counted strings in the Files view and the
event editor had never been in any of the nine catalogues, so they rendered in
English whatever language was chosen.

Not a regression from the recent work -- they have been missing since the
features landed, and every earlier scan looked at t("literal") sites and the
plurals of whichever file was in hand.

All nine languages, one commit rather than nine: this is a single gap in a
check rather than a translation pass, and splitting it per language would
suggest nine decisions where there is one.
2026-09-03 14:44:23 -07:00
Coffey Labs 71827a2d04 Merge pull request #270 from Coffey-Labs/i18n-ukrainian-rule-sentences
Translate the rule sentences into Ukrainian
2026-09-03 14:41:45 -07:00
Coffey Labs 5dd0a56732 Merge pull request #269 from Coffey-Labs/i18n-russian-rule-sentences
Translate the rule sentences into Russian
2026-09-03 14:41:39 -07:00
Coffey Labs b55c8b13bc Merge pull request #268 from Coffey-Labs/i18n-chinese-rule-sentences
Translate the rule sentences into Simplified Chinese
2026-09-03 14:41:35 -07:00
Coffey Labs 0cf9b81444 Merge pull request #267 from Coffey-Labs/i18n-japanese-rule-sentences
Translate the rule sentences into Japanese
2026-09-03 14:41:31 -07:00
Coffey Labs 8386444ac7 Merge pull request #266 from Coffey-Labs/i18n-portuguese-rule-sentences
Translate the rule sentences into Brazilian Portuguese
2026-09-03 14:41:26 -07:00
Coffey Labs e1ae97139c Merge pull request #265 from Coffey-Labs/i18n-dutch-rule-sentences
Translate the rule sentences into Dutch
2026-09-03 14:41:21 -07:00
Coffey Labs 503eaf17ec Merge pull request #264 from Coffey-Labs/i18n-french-rule-sentences
Translate the rule sentences into French
2026-09-03 14:41:16 -07:00
Coffey Labs 1e02d9ebba Merge pull request #263 from Coffey-Labs/i18n-spanish-rule-sentences
Translate the rule sentences into Spanish
2026-09-03 14:41:11 -07:00
Coffey Labs d40dbf04b8 Merge pull request #262 from Coffey-Labs/i18n-german-rule-sentences
Translate the rule sentences into German
2026-09-03 14:41:06 -07:00
Coffey Labs 2a9e18f04c Merge pull request #271 from Coffey-Labs/fix/shortcuts-after-checkbox
Keep shortcuts working after a checkbox is clicked
2026-09-03 14:41:00 -07:00
Coffey Labs 4d89f5e672 Merge pull request #261 from Coffey-Labs/i18n-describe-rules
Build the two rule descriptions as sentences, not fragments
2026-09-03 14:40:55 -07:00
jcoffey-dev 95e5c69e8f Keep shortcuts working after a checkbox is clicked
Ticking "select all" disabled every keyboard shortcut until the reader clicked
somewhere else (#260). Same for the per-message checkboxes, so selecting a few
messages and pressing e to archive them did nothing.

The guard that stops "a" archiving while you are typing into the search box
tested `tagName === "INPUT"`. That is also true of a checkbox, and a checkbox
keeps focus after a click -- correctly, since space should toggle it again.
So the guard was suppressing shortcuts for an element that swallows no
keystroke: space is handled by the browser before this listener runs.

The question is not "is this an input" but "does this input take text", which
is what isTextEntry now asks. A <select> counts, in the sense that matters
here: typing a letter jumps to the option starting with it, and a shortcut
would steal that.

Thirteen checkboxes and seven file inputs across the app were affected, not
just the one reported.

The regression test was checked against the old guard first: it fails there
and passes here, which is the only thing that makes it a regression test.
2026-09-03 14:38:15 -07:00
jcoffey-dev 50d08a18e4 Translate the rule sentences into Ukrainian
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Ukrainian half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:24:12 -07:00
jcoffey-dev 9a634311b2 Translate the rule sentences into Russian
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Russian half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:24:08 -07:00
jcoffey-dev b811c84b12 Translate the rule sentences into Simplified Chinese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Simplified Chinese half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:23:32 -07:00
jcoffey-dev b9b01ce02c Translate the rule sentences into Japanese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Japanese half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:23:28 -07:00
jcoffey-dev 104e3c7ba0 Translate the rule sentences into Brazilian Portuguese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Brazilian Portuguese half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:23:01 -07:00
jcoffey-dev 0a03c64ff3 Translate the rule sentences into Dutch
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Dutch half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:22:58 -07:00
jcoffey-dev 4c430ea995 Translate the rule sentences into French
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the French half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:22:28 -07:00
jcoffey-dev 112b3ea52f Translate the rule sentences into Spanish
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Spanish half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:22:24 -07:00
jcoffey-dev 31edf33839 Translate the rule sentences into German
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the German half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:21:53 -07:00
jcoffey-dev 1a842d8d14 Build the two rule descriptions as sentences, not fragments
Both describeRule functions assembled their output by concatenation, which no
catalogue could fix. A translator handed " and " or " on " in isolation cannot
move it: German puts the verb last, Japanese does not separate list items with
a word at all, and the fragments arrive in an order the English sentence chose.
Reported by a native speaker reviewing the German catalogue (#247), whose "the
summaries" item is the Sieve one.

Every branch is now one whole sentence with placeholders, so a translator
rewrites the sentence including its word order. Joining is Intl.ListFormat,
which gives "A, B und C" for an allof rule and the language's own disjunction
for anyof, rather than a hardcoded " and " that would be wrong twice over.

The recurrence tail no longer appends: ", 5 times" and ", until 2026-05-03"
wrap the sentence they qualify, so a language that puts the limit first can.

Ordinals become words. The old suffix table -- st, nd, rd, th, picked by
arithmetic -- is English spelling rules in code, and no catalogue can reach a
suffix chosen that way. German writes "1.", Japanese "第1". nthOfPeriod is 1-5
or -1 in practice, so five words and "last" cover it.

WEEKDAYS is gone. Its long names could have been catalogue entries but its
short ones never could: "T" is Tuesday and Thursday, "S" is Saturday and
Sunday, and a catalogue cannot hold two translations under one key. That was
bad data rather than missing translation, and Intl has every name in every
locale in three widths. lib/datetime.ts gains weekdayName, weekdayNames and
formatList; recurrence.ts keeps WEEKDAY_KEYS for the ordering, which is not a
language question.

Adds the first tests either function has had. Neither had any, and no test
would have caught what was wrong with them, since the English output was
correct -- so these pin the two properties that actually matter: fragments go
through the catalogue, and the joining is Intl's.

32 strings and 9 plural forms are new and land with each language.

Verified: typecheck clean, 1009 tests pass.
2026-09-03 14:20:50 -07:00
Coffey Labs 22f39a4507 Merge pull request #259 from Coffey-Labs/i18n-third-pass
Third pass: the strings libraries build and views render raw
2026-09-03 14:01:53 -07:00
Coffey Labs bf60fe6157 Merge pull request #258 from Coffey-Labs/i18n-second-pass
Second pass: four dialogs that were never wrapped
2026-09-03 14:01:49 -07:00
Coffey Labs 8d4063e921 Merge pull request #257 from Coffey-Labs/i18n-chinese-catch-up
Catch the Simplified Chinese catalogue up with what shipped after it
2026-09-03 14:01:44 -07:00
Coffey Labs ea6c098057 Merge pull request #256 from Coffey-Labs/i18n-ukrainian-catch-up
Catch the Ukrainian catalogue up with what shipped after it
2026-09-03 14:01:38 -07:00
Coffey Labs f7cbdb2e7a Merge pull request #255 from Coffey-Labs/i18n-russian-catch-up
Catch the Russian catalogue up with what shipped after it
2026-09-03 14:01:33 -07:00
Coffey Labs 379623614a Merge pull request #254 from Coffey-Labs/i18n-japanese-catch-up
Catch the Japanese catalogue up with what shipped after it
2026-09-03 14:01:27 -07:00
Coffey Labs 662385c6da Merge pull request #253 from Coffey-Labs/i18n-portuguese-catch-up
Catch the Brazilian Portuguese catalogue up with what shipped after it
2026-09-03 14:01:21 -07:00
Coffey Labs 0fb4d1e964 Merge pull request #252 from Coffey-Labs/i18n-dutch-catch-up
Catch the Dutch catalogue up with what shipped after it
2026-09-03 14:01:16 -07:00
Coffey Labs 898815eaca Merge pull request #251 from Coffey-Labs/i18n-french-catch-up
Catch the French catalogue up with what shipped after it
2026-09-03 14:01:09 -07:00
Coffey Labs 6ac5ee45dd Merge pull request #250 from Coffey-Labs/i18n-spanish-catch-up
Catch the Spanish catalogue up with what shipped after it
2026-09-03 14:01:04 -07:00
Coffey Labs f500261697 Merge pull request #249 from Coffey-Labs/i18n-german-catch-up
Catch the German catalogue up with what shipped after it
2026-09-03 14:00:59 -07:00
Coffey Labs 7a67a2c1c3 Merge pull request #248 from Coffey-Labs/i18n-render-untranslated-lists
Translate three lists the code was rendering raw
2026-09-03 14:00:54 -07:00
jcoffey-dev 9245fc5b1e Add the third-pass strings to Ukrainian
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled
send, the read-receipt explanations, compose toasts and thrown errors -- plus
two swipe labels that reach t() through a variable and so were invisible to a
scan for t("literal") call sites.

17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix
appended in the code, which is English grammar the catalogue could not reach.
2026-09-03 13:56:25 -07:00
jcoffey-dev 9ed64bea88 Add the third-pass strings to Russian
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled
send, the read-receipt explanations, compose toasts and thrown errors -- plus
two swipe labels that reach t() through a variable and so were invisible to a
scan for t("literal") call sites.

17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix
appended in the code, which is English grammar the catalogue could not reach.
2026-09-03 13:56:05 -07:00
jcoffey-dev 21d0320765 Add the third-pass strings to Simplified Chinese
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled
send, the read-receipt explanations, compose toasts and thrown errors -- plus
two swipe labels that reach t() through a variable and so were invisible to a
scan for t("literal") call sites.

17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix
appended in the code, which is English grammar the catalogue could not reach.
2026-09-03 13:55:45 -07:00
jcoffey-dev 2a5c6a5c18 Add the third-pass strings to Japanese
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled
send, the read-receipt explanations, compose toasts and thrown errors -- plus
two swipe labels that reach t() through a variable and so were invisible to a
scan for t("literal") call sites.

17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix
appended in the code, which is English grammar the catalogue could not reach.
2026-09-03 13:55:43 -07:00
jcoffey-dev e5c8594901 Add the third-pass strings to Brazilian Portuguese
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled
send, the read-receipt explanations, compose toasts and thrown errors -- plus
two swipe labels that reach t() through a variable and so were invisible to a
scan for t("literal") call sites.

17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix
appended in the code, which is English grammar the catalogue could not reach.
2026-09-03 13:55:19 -07:00
jcoffey-dev d844786e14 Add the third-pass strings to Dutch
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled
send, the read-receipt explanations, compose toasts and thrown errors -- plus
two swipe labels that reach t() through a variable and so were invisible to a
scan for t("literal") call sites.

17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix
appended in the code, which is English grammar the catalogue could not reach.
2026-09-03 13:55:17 -07:00
jcoffey-dev 1b75580da2 Add the third-pass strings to French
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled
send, the read-receipt explanations, compose toasts and thrown errors -- plus
two swipe labels that reach t() through a variable and so were invisible to a
scan for t("literal") call sites.

17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix
appended in the code, which is English grammar the catalogue could not reach.
2026-09-03 13:54:53 -07:00
jcoffey-dev c0faed2c2a Add the third-pass strings to Spanish
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled
send, the read-receipt explanations, compose toasts and thrown errors -- plus
two swipe labels that reach t() through a variable and so were invisible to a
scan for t("literal") call sites.

17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix
appended in the code, which is English grammar the catalogue could not reach.
2026-09-03 13:54:41 -07:00
jcoffey-dev bb5a26c343 Add the third-pass strings to German
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled
send, the read-receipt explanations, compose toasts and thrown errors -- plus
two swipe labels that reach t() through a variable and so were invisible to a
scan for t("literal") call sites.

17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix
appended in the code, which is English grammar the catalogue could not reach.
2026-09-03 13:53:49 -07:00
jcoffey-dev be088ed78d Third pass: the strings libraries build and views render raw
A sweep of lib/ and store/ for user-facing English, after the views were done.
The pattern here is the one #248 found: a module returns an English sentence
and the view renders it without asking for a translation.

Scheduled send was entirely untranslated. The four presets -- Later today,
Tomorrow morning, Tomorrow afternoon, Monday morning -- were rendered raw, and
scheduleError() returned three English sentences straight to the picker.
describeSpan() built "30 days" by appending an "s" unless the count was one,
which is English grammar written into the code: it produces the right German
only by the two languages happening to agree, and Russian needs three forms.
It is plural() now. scheduleError translates in place rather than returning a
key, because it composes a sentence around that span.

Read receipts: refusalText() returns five explanations, all rendered raw in
MessageView, and the offered case was a template literal -- "Requested, to
x@y. Never sent automatically." -- with the address concatenated in. It takes
a placeholder now, so the sentence can be reordered.

Compose: the sending toast and its Undo, the Open draft action, the two
attachment failures, and three thrown errors that surface to the reader as
toasts.

Two swipe labels, Add star and Remove star, are rendered through
t(desc.label) and have never been in any catalogue -- the coverage scan that
found the other 176 only looked at t("literal") sites, so labels reaching t()
through a variable were invisible to it. Every such table is now enumerated
and all 61 values checked: these two were the only ones missing.

17 strings and 3 plural forms are new and land with each language.

Verified: typecheck clean, 1000 tests pass.
2026-09-03 13:52:24 -07:00
jcoffey-dev 20b6475f18 Second pass: four dialogs that were never wrapped
A sweep for UI text still rendering in English, after the nine catalogues were
brought up to date. Four dialogs were building their own English:

  - The delete confirmation in MailView, entirely: both titles, both messages
    and the confirm label. Its counts read "message(s)", which is a
    parenthesis standing in for agreement -- every language that inflects got
    the wrong form. They are plural() calls now.
  - Rename, in the Files tree.
  - New address book, and its Name placeholder.
  - New category, and its Name placeholder. The button opening that dialog was
    already translated, which is how it went unnoticed: the label read right
    and the dialog it opened did not.

Rename, New address book, New category, Name and Delete are already in all
nine catalogues. Delete?, Delete forever? and the two plural forms are new and
land with each language.

Verified: typecheck clean, 1000 tests pass.
2026-09-03 11:58:17 -07:00
jcoffey-dev 31feb4114a Catch the Simplified Chinese catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: 您 where the reader is addressed
directly and the pronoun dropped everywhere it can be, and the fixed
terminology, so 收件箱, 文件夹, 邮件, 会话, 标签, 已删除邮件, 草稿 and 设置
read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from zh-Hans.ts.
2026-09-03 11:54:32 -07:00
jcoffey-dev 2280df1ea9 Catch the Ukrainian catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: ви in lowercase, the infinitive
for actions, and the terminology that keeps this a Ukrainian catalogue rather
than the Russian one with a different name on it -- Тека and not папка, Мітка
and not ярлик, which in Ukrainian means a shortcut. Вхідні, Лист, Листування,
Кошик, Чернетки and Налаштування read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from uk.ts.
2026-09-03 11:52:41 -07:00
jcoffey-dev 083039b27e Catch the Russian catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: вы in lowercase rather than
correspondence-style «Вы», the infinitive for actions, and the fixed
terminology, so Входящие, Папка, Письмо, Цепочка, Ярлык, Корзина, Черновики
and Настройки read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from ru.ts.
2026-09-03 11:50:29 -07:00
jcoffey-dev d525b18f6d Catch the Japanese catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: です・ます throughout, no あなた,
bare noun or verb stem on buttons, and the deliberate script mixing -- kanji
for the noun carrying the meaning, katakana for the loanword the reader knows,
long vowels keeping their ー. So 受信トレイ, フォルダー, メール, スレッド,
ラベル, ゴミ箱, 下書き and 設定 read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from ja.ts.
2026-09-03 11:48:32 -07:00
jcoffey-dev f5ba09ef77 Catch the Brazilian Portuguese catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: você rather than the formal
address the other Phase 1 languages took, and the fixed terminology, so Caixa
de entrada, Pasta, Mensagem, Conversa, Marcador, Lixeira, Rascunhos and
Configurações read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from pt-BR.ts.
2026-09-03 11:46:28 -07:00
jcoffey-dev 3b77fb85fd Catch the Dutch catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: u throughout, and the fixed
terminology, so Postvak IN, Map, Bericht, Gesprek, Label, Prullenbak,
Concepten and Instellingen read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from nl.ts.
2026-09-03 11:44:27 -07:00
jcoffey-dev b05cd178e6 Catch the French catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: vous throughout, guillemets for
quoted names, a plain space before ? and : rather than a narrow no-break one,
and the fixed terminology, so Boîte de réception, Dossier, Message, Libellé,
Corbeille, Brouillons and Paramètres read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from fr.ts.
2026-09-03 11:42:30 -07:00
jcoffey-dev f2437b6904 Catch the Spanish catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: usted throughout, peninsular
Spanish, and the fixed terminology, so Bandeja de entrada, Carpeta, Mensaje,
Conversación, Etiqueta, Papelera, Borradores and Configuración read the same
here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from es.ts.
2026-09-03 11:40:36 -07:00
jcoffey-dev ba16067529 Catch the German catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. A native speaker reviewing this file
reported it (#247), which is the review the language was marked Beta waiting
for.

176 entries: 153 from features that shipped after the translation pass --
privacy and safety settings, the message ordering controls, subscribed and
birthday calendars, label nesting, the file preview, the unsaved-changes
prompts -- and 23 keyboard bindings, whose group and description are
registered in English at the call site and are translated at render.

Follows the decisions this file already pins: Sie throughout, and the fixed
terminology, so Ordner, Nachricht, Konversation, Posteingang, Papierkorb,
Entwürfe and Label read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English,
which is the correct rendering for them: example.com and the other input
placeholders, the product names, and the verbatim header names List-Id and
X-Spam-Status. A translator should not be invited to translate example.com.

Verified: typecheck clean, 1000 tests pass, and a coverage pass over every
t() call site reports nothing missing from de.ts.
2026-09-03 11:37:56 -07:00
jcoffey-dev b10149fd54 Translate three lists the code was rendering raw
A native speaker reviewing the German catalogue reported strings appearing in
English (#247). Three of the reported areas turned out not to be missing
translations at all: the strings were there, and the code was rendering the
English source instead of asking for one.

The Sieve rule dialog rendered HEADER_CHOICES and HEADER_OPS labels directly.
All sixteen are already in every catalogue -- "Subject" has been "Betreff" in
de.ts all along, which is exactly the inconsistency the reporter noticed
against the Out-of-office page, where it already reads Betreff. Wrapping the
two dropdowns fixes nine languages at once and adds nothing to any catalogue.

The keyboard shortcut panel rendered each binding's group and description
directly. Those are registered in English at the call sites and should stay
that way -- the binding table is data and the English is the catalogue key --
so the panel translates them at render instead. A binding added anywhere is
then translatable without its registrar knowing i18n exists.

The palette grid marks every name translate="no". That is right for ihasmail,
Dracula, Gruvbox, Rosé Pine and Tokyo Night, which are names. "Classic" is an
adjective describing the theme, not a name, so PaletteMeta gains a
`translatable` flag for the one entry that is a word. Flagging the exception
beats dropping the attribute from all six.

No catalogue changes here: the strings the first two need are already present
in all nine. "Classic" needs an entry, which lands with each language.
2026-09-03 11:37:05 -07:00
Coffey Labs 3ffee1224f Merge pull request #246 from Coffey-Labs/ci/prune-old-images
Prune old images, and keep every release
2026-09-02 23:50:13 -07:00
jcoffey-dev 4b2c97df4e Prune old images, and keep every release
Two artefacts, opposite answers.

Releases stay. They carry no assets -- the image lives in GHCR -- so one costs
a tag, a title and generated notes, and with no CHANGELOG in this repository
those notes are the only changelog there is. Deleting one destroys history
that cannot be reconstructed, and saves nothing.

Images accumulate: a multi-architecture build a week, and the by-digest push
leaves two untagged per-architecture manifests behind each time on top of the
tagged index. Ten tagged versions are kept, which is roughly a quarter of
releases and far more than anything anyone rolls back to.

The obvious tool for this is a trap. delete-package-versions with
`delete-only-untagged-versions` will delete the per-architecture manifests
that a multi-arch tag points at, because they are untagged by design, and
nothing appears to break: the tag still resolves and pulls simply start
failing for one architecture. This action understands manifest lists and
leaves a retained index's children alone, `validate` re-checks every
multi-arch manifest against the registry afterwards, and `latest` is excluded
from consideration entirely.

It is pinned to a commit rather than a major tag. It holds `packages: write`
and its whole purpose is deletion, so a tag repointed upstream is not a risk
worth carrying for the convenience.

Kept in its own file and dispatchable, so a dry run can show exactly what
would go without rebuilding and re-pushing an image to find out.
2026-09-02 23:47:22 -07:00
Coffey Labs 60a4647a43 Merge pull request #245 from Coffey-Labs/ci/scheduled-releases
Cut a release once a week, and only when there is something in it
2026-09-02 23:45:41 -07:00
jcoffey-dev 5cb0b2f3ea Cut a release once a week, and only when there is something in it
Publishing on release is the right trigger only if releases happen. They had
not: main ran 184 commits ahead of the last one, so `:latest` described a
build that neither the demo, nor production, nor anyone building from source
was running. This is the part that makes the trigger true without anyone
having to remember.

Mondays at 09:00 UTC. A run with no commits since the last release does
nothing at all -- an empty release moves `:latest` to an identical build,
spends a version number and mails every watcher about nothing. The decision is
written to the run summary either way, so a quiet week reads as a decision
rather than as a workflow that failed silently.

The awkward part is that a release created with GITHUB_TOKEN raises no
`release` event: GitHub refuses to let a token trigger another workflow, to
stop a workflow looping on its own output. A scheduled job that cut a release
and left publish.yml to notice would tag the commit and never build an image,
which is the kind of failure that looks like success. So publish.yml gains a
`workflow_call` trigger and this calls it directly. The alternative was a
personal access token kept as a secret; this needs no credential.

Two smaller decisions. Drafts are excluded when looking for the last release,
because an unpublished draft is not a release anybody has and counting from it
would hide commits that never shipped. And if the tag a release names has
gone, the count falls back to the whole history -- over-counting cuts a
release that was due anyway, where under-counting skips one that was not.
2026-09-02 23:43:12 -07:00
Coffey Labs e1c85cf82e Merge pull request #244 from Coffey-Labs/ci/publish-container-image
Publish the image the docs have been telling people to pull
2026-09-02 23:30:07 -07:00
jcoffey-dev 1734ed0439 Publish the image the docs have been telling people to pull
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.
2026-09-02 23:22:21 -07:00
Coffey Labs 93856de143 Merge pull request #243 from Coffey-Labs/fix/login-app-name
Call the instance what it calls itself, on the page that matters most
2026-09-02 15:27:20 -07:00
jcoffey-dev cfcaf5f573 Call the instance what it calls itself, on the page that matters most
APP_NAME is a runtime variable and two of the three places showing the name
ignored it. The sign-in page fetched /api/config, received the name and used
only sourceUrl -- so a rebranded deployment still said "ihasmail" on the one
page a new user meets first. The top bar had it written in. Only the document
title read it, and it had been reading it from the session all along.

The rebranding guide documents both as things to patch yourself, one of them
with "if you change nothing else on this page, change this". It should not
have to.

The sign-in page takes the name from the answer it was already getting. The
top bar takes it from the session, where the title has taken it from since it
was written. Neither is a new request.

One shared default rather than the string written out at three call sites,
because three copies of a default is how two of them end up stale. It stands
if the config request fails, since a sign-in form with no name on it would be
worse than one with the wrong name -- and an empty or non-string name falls
back too, so a deployment that sets APP_NAME= does not get a nameless page.

Confirmed with APP_NAME set to something else: sign-in heading, top bar and
tab title all read it.
2026-09-02 15:24:36 -07:00
Coffey Labs 3582ad116e Merge pull request #242 from Coffey-Labs/feat/contacts-update-on-reimport
Update a contact on re-import rather than skipping it
2026-09-02 15:17:27 -07:00
jcoffey-dev 9f4bd65fd5 Update a contact on re-import rather than skipping it
#228 skipped a vCard whose UID the book already held. The reporter asked for
the opposite on #174 and he is right: the reason to import a file a second
time is usually that the first one was not right, so skipping means a
corrected export corrects nothing.

A merge, not a replacement. Properties the file carries overwrite what is
here; properties it does not mention are left alone, so a phone number added
in ihasmail after the first import survives a re-import of the original file.
The cost is that a field genuinely deleted at the source stays here, which is
the better way to be wrong -- the other way round loses work nobody asked to
lose. Worth confirming with him rather than assuming.

`addressBookIds` is left off the patch. The card is already in this book, so
saying it again says nothing, and saying it on a card that is also in another
book would move it.

Creates and updates now share one batch budget. Stalwart counts every object
in a /set together, so batching the halves separately would send 300 new and
300 changed as two calls of 300 and be refused for a limit of 500 that neither
half exceeds.

LDIF is untouched and still reports look-alikes without acting on them, since
what it should match on is the question still open on #223. Both imports keep
one answer shape so a caller need not know which it called; LDIF's `updated`
is always 0, which is the honest number rather than a missing field.

The message a vCard attached to a message shows changes with it: the newer
copy now wins instead of being dropped, so it says the contact was brought up
to date rather than that nothing was added.

Refs #223.
2026-09-02 15:06:04 -07:00
Coffey Labs af0e8b2be2 Merge pull request #241 from Coffey-Labs/feat/domain-server-mapping
Choose the Stalwart by the domain somebody signs in with
2026-09-02 14:33:14 -07:00
jcoffey-dev 171c11fc92 Choose the Stalwart by the domain somebody signs in with
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.
2026-09-02 14:18:50 -07:00
Coffey Labs 2a41a31bb9 Merge pull request #240 from Coffey-Labs/fix/upstream-outage-lockout
Do not spend login attempts on an outage nobody caused
2026-09-02 14:14:24 -07:00
jcoffey-dev 607afeb4ad Do not spend login attempts on an outage nobody caused
ihasmail runs in its own container, usually on its own host, so Stalwart being
briefly unreachable is an ordinary Tuesday. Sign-in handled it almost right:
a 401 is invalid_credentials, a timeout is 504 and anything else is 502, none
of which reads as a rejected password.

What it got wrong was the counting. RateLimiter.check() consumes an attempt
when it is called, and it is called before the upstream is contacted; reset()
only runs on success. So every try against an unreachable server burned a
credential attempt, and after ten of them the person was locked out for the
rest of the fifteen-minute window -- including after the server came back. A
thirty-second blip became a quarter-hour lockout, and the second failure was
entirely ihasmail's own doing.

A 401 is a judgement about the password and stays counted. A 502 or 504 is the
upstream failing to answer, says nothing about the credentials, and is now
refunded -- one attempt back, not the key cleared, so a run of real failures
with an outage in the middle still adds up. The old-server refusal refunds too:
those credentials were accepted.

Both guessing keys are refunded, not just the username one. Refunding only
that would not have fixed it -- ten retries still spend the per-address budget,
and behind one office NAT that budget belongs to the whole building, so a
company-wide outage would lock out the company.

Which needs a backstop, because "not counted" must not mean "unlimited": each
attempt still costs an outbound connection that may sit there until
UPSTREAM_TIMEOUT, and an outage is the one moment the endpoint is cheapest to
abuse. So there is a second ceiling per address, twenty times looser and never
refunded. A person retrying will not come near it; something hammering will.

Both messages now say the quiet part -- "This is not a problem with your
password" -- for somebody already worried they have forgotten it.

Closes #239.
2026-09-02 14:08:23 -07:00
Coffey Labs f6f6ce0b5e Merge pull request #237 from Coffey-Labs/feat/headers-show-original
Make "Show original" in the headers dialog the action, not a description
2026-09-02 13:23:01 -07:00
jcoffey-dev b10ce2f9dd Make "Show original" in the headers dialog the action, not a description
The hint at the foot of Message headers named an action and left you to go
find it. Requested in #236, and the reporter is right that it is the shape of
the thing rather than the size: telling somebody a feature exists is half a
job when the other half is one element away.

Clicking it now closes the headers dialog and opens the original, so it reads
as going deeper rather than as opening a second window.

`tNode` rather than a sentence chopped either side of a button: the sentence
stays whole for whoever translates it, and a language that puts the verb
somewhere else can move the hole rather than being handed two fragments.

The link style needed unscoping to work, which turned out to be a bug of its
own. `.link-btn` was written for the composer's To and Cc labels and scoped to
`.composer-field label`, so the two callers outside it -- the trusted-domain
list in Privacy settings, and now this -- rendered as default button chrome in
the middle of a sentence. The rule is now unscoped and Privacy is fixed by the
same change.

Checked in a browser: the hint reads as a sentence with a dotted-underlined
link in it, clicking swaps one dialog for the other, and the raw message is
there.

Closes #236.
2026-09-02 13:17:10 -07:00
Coffey Labs 45f1309d06 Merge pull request #235 from Coffey-Labs/feat/go-to-folder
Go to a folder by name, with g then o
2026-09-02 13:07:52 -07:00
jcoffey-dev 483aac849a Go to a folder by name, with g then o
Requested in #233. The `g` shortcuts cover the handful of folders every
account has -- inbox, sent, drafts -- and nothing reaches the dozens a Sieve
rule fills, which is where somebody with a real folder tree spends their time.
`g o` opens the picker, you type part of a name, and you are there.

The picker is the one the move action already uses, with one difference that
only shows up on shared mail: it selected folders by `mayAddItems`, which is
right for a destination and wrong for a place to go. A shared folder you may
read but not file into is somewhere you can visit. The right is now a
parameter, named for what it is asking rather than for which caller wants it.

Hosted in AppShell rather than in the mail view, because the `g` shortcuts are
global and the mail view is not mounted to hear about it -- pressing this from
the calendar should still take you to a folder, and now does.

`o` on its own opens a conversation and does not clash: a pending prefix is
tried before a bare key. That was already true and nothing said so, so there
are now five tests for the sequence machinery -- including that an abandoned
prefix costs the prefix and not the keystroke after it, which is the nicer
behaviour of the two and was undocumented.

Checked in a browser against the mock: opened from the calendar, filtered to a
nested folder, landed on it, and `o` still opened a conversation afterwards.

Closes #233.
2026-09-02 13:03:55 -07:00
Coffey Labs a8cf8ce3d7 Merge pull request #234 from Coffey-Labs/feat/ldif-duplicate-report
Say how much an LDIF re-import duplicated, without acting on it
2026-09-02 12:51:10 -07:00
jcoffey-dev 9622875659 Say how much an LDIF re-import duplicated, without acting on it
The half of #223 that can move while the matching question is still open.

Mozilla's schema defines no UID, so the import invents one and a re-import
duplicates everything. Whether to guess an identity from a name and an address
instead is the reporter's call and he has not made it -- but the harm that was
actually reported was confusion rather than duplication: somebody imports a
file twice and cannot tell what happened.

So the import now counts how many of the entries look like contacts the book
already held, and says so in a second message. Every card is still imported.
Nothing is skipped and nothing is merged, which is the point: counting is a
different act from matching, and it takes no decision away from the person who
still owes us one.

The likeness key is name plus one address, and it is wrong in both directions
by design -- two colleagues sharing a name and an alias collapse, somebody
whose address changed since the last export looks like a stranger. That is
tolerable for a number on a toast and would not be tolerable for a merge,
which is exactly why the number is all it does.

The scan the vCard import already makes for UIDs now collects names and
addresses on the same request, so this costs no extra round trip. It is read
before anything is created, so a file that repeats a person twice counts as
two new cards rather than as a duplicate of itself.

If the answer comes back "match on name and email", the matching is written
and becomes a skip instead of a count.

Refs #223.
2026-09-02 12:45:30 -07:00
Coffey Labs 0d5975d427 Merge pull request #232 from Coffey-Labs/docs/policy-example
Ship an example settings policy, and name the variables in .env.example
2026-09-02 11:23:15 -07:00
jcoffey-dev a24b4c5538 Ship an example settings policy, and name the variables in .env.example
#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.
2026-09-02 11:20:34 -07:00
Coffey Labs cff7f6c11b Merge pull request #231 from Coffey-Labs/feat/policy-change-once
Apply installation policy changes once each, per account
2026-09-02 11:03:50 -07:00
jcoffey-dev c31a653a04 Apply installation policy changes once each, per account
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.
2026-09-02 11:00:48 -07:00
Coffey Labs 6821d6a93f Merge pull request #230 from Coffey-Labs/feat/installation-defaults
Let an installation seed and lock user settings
2026-09-02 10:52:58 -07:00
jcoffey-dev 457ea53ca3 Let an installation seed and lock user settings
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.
2026-09-02 10:49:55 -07:00
Coffey Labs a01e1874d8 Merge pull request #229 from Coffey-Labs/fix/export-vtimezone
Define the zones an export names, instead of only naming them
2026-09-02 10:29:45 -07:00
jcoffey-dev 0e58b886b5 Define the zones an export names, instead of only naming them
#227 emitted TZID with the IANA name and nothing defining it, on the reasoning
that every client resolves those names and that generating a definition would
mean shipping a zone database. Both halves were wrong.

Measured, not assumed. Run an export through ical.js -- Mozilla's own
iCalendar library, the one Thunderbird's calendar uses -- and a TZID with no
VTIMEZONE beside it does not resolve: it falls back to floating time. A 09:00
in Phoenix then reads as 09:00 wherever the file is opened, seven hours out,
silently, on every timed event in every export.

    as exported            | zone: floating        | UTC: 09:00Z
    with a VTIMEZONE added | zone: America/Phoenix | UTC: 16:00Z

The database was already here, too. The browser has IANA behind Intl, and an
offset for an instant is a formatting question: format the instant into the
zone, read the clock back, and the difference is the offset. Transitions are
found by walking month by month for the ones where the answer changes and
bisecting inside them -- no rules are known, so none can be got wrong.

Each transition is its own dated sub-component rather than an RRULE. More
lines and no cleverness: a derived rule that is subtly wrong moves somebody's
meeting, while a list of dates can only be incomplete at its ends, which is
what the window is for -- the year before the earliest event to ten years past
the latest, an open-ended weekly meeting being the case that needs it.

A zone Intl does not know is left undefined rather than described from
nothing; the TZID stays on the event, which is where it was. TZNAME is dropped
where Intl offers "GMT+9", which only repeats the offset beside it.

Confirmed the same way it was found. Berlin now resolves to +0200 in September
and +0100 in December, so the transitions are being applied and not just an
offset.

Refs #216.
2026-09-02 10:26:43 -07:00
Coffey Labs 5f35428ddb Merge pull request #228 from Coffey-Labs/feat/vcard-uid-dedupe
Skip vCards on re-import that the address book already has
2026-09-02 09:36:35 -07:00
jcoffey-dev a1fe4fea1a Skip vCards on re-import that the address book already has
The contacts half of the rule that shipped for events, and only the half that
can be decided. A vCard carries a UID its author meant, so a card whose UID
this book already holds is that card, and re-importing an export left a second
copy of every one of them. Reported on #174 by the reporter's colleague, and
decided on #173: skip on a UID that is already here, import what arrives
without one, since nothing can be matched on an identity that is not there.

LDIF is deliberately untouched and now says so in the type. Mozilla's schema
defines no UID and the dn is not an identity outside the directory it came
from, so the import invents a UID that can never match one already present.
Guessing instead from a name and an address is the open question on #223, and
a guess that merges two people who share a name is worse than a duplicate
somebody can see and delete.

Both imports answer with the same shape, so a caller does not have to know
which one it called. LDIF's skipped is always 0, which is the honest number
rather than a missing field.

The UIDs are asked of the server rather than read from the cards in the store.
The store's copy is complete once the view has loaded, and importing does not
wait for a view.

Two callers, two messages. The contacts import reports both counts, as the
calendar import does: "Imported 3 contacts" over a file of two hundred reads
as a failure when the rest were already here. And a vCard attached to a
message -- usually one you have been sent before -- now says it is already in
your contacts rather than reporting that it added none.

Refs #223; the LDIF half stays open.
2026-09-02 09:33:53 -07:00
Coffey Labs 85289b0c34 Merge pull request #227 from Coffey-Labs/feat/calendar-ics-export
Export a calendar as an iCAL file
2026-09-02 09:24:00 -07:00
jcoffey-dev 1a6158aa70 Export a calendar as an iCAL file
The mirror of the import from #173, and the last thing contacts had that
calendars did not -- an address book could always be exported, a calendar
never could.

It is written here rather than asked for. The import hands parsing to the
server because Stalwart has a CalendarEvent/parse and reimplementing an .ics
reader in a browser would be foolish; there is no method the other way, in
Stalwart or in the JMAP calendar drafts, so the file is built from the RFC
8984 objects the server already returns. Most of that is renaming: 8984 was
written as a restatement of 5545, and the comments say which way it went
wherever the two disagree.

The masters, not the occurrences. The query runs without expandRecurrences,
so a weekly meeting leaves as one VEVENT carrying its RRULE rather than as a
year of identical ones -- an export that had flattened the rule would import
somewhere else as a pile nobody can maintain. A changed occurrence goes out
as its own VEVENT with the same UID and a RECURRENCE-ID, which is how
iCalendar has always said it; a cancelled one becomes an EXDATE.

Three decisions worth stating rather than leaving to be found:

No VTIMEZONE components. A TZID names the IANA zone the server holds and
nothing defines it beside it, because defining it means shipping a zone
database to describe rules the reader's own system already knows. Every
client that matters resolves IANA names. The alternative -- converting to
UTC -- would be worse than a validator's complaint: a weekly 09:00 that
becomes 08:00 for half the year is a wrong calendar.

UNTIL follows DTSTART's kind, a date for an all-day series and a UTC instant
otherwise. Sending a local time there is the usual way to make a series stop
a day early in another timezone.

Overrides are applied at the top level only. A recurrence override is a JSON
patch, and one addressing locations/x/name is not something this flattens.

Closes #216.
2026-09-02 09:21:16 -07:00
Coffey Labs cb69115be1 Merge pull request #226 from Coffey-Labs/feat/contacts-menu-parity
Give the address books the menus the calendars have
2026-09-02 09:08:40 -07:00
jcoffey-dev 8badf48c4a Give the address books the menus the calendars have
Two remarks from the reporter's colleague, both the same underlying thing:
contacts and calendar grew their menus at different times and it shows.

The dots button on hover. The calendar has offered its per-item menu two ways
since it was written -- the button and right-click -- and contacts only had
right-click, which is undiscoverable and unavailable on touch. The rows are
already .nav-item, which has carried the hover-reveal rule for mail folders
all along, so this is the button and no CSS.

Import and export move into those menus. As a pair of buttons at the foot of
the sidebar they did not say which address book they acted on -- they meant
"whatever is selected", which is not something a button can tell you. The
calendar settled this already: its iCAL import lives in the calendar's own
menu, because that is where "which one?" is answered by where you clicked.
The events they dispatch now name the book instead of meaning the selection.

Exporting a book now exports that book, rather than the list on screen. The
old one handed you whatever was showing, so a search box with something in it
quietly narrowed the export -- fine while the button sat under that list,
wrong from a menu in the sidebar.

Two things that would otherwise have been lost with the buttons. "All
contacts" gets the same menu, so exporting everything still has a home; and
a book somebody shared gets a menu rather than the bare X, since it can be
exported too and losing that would have been a regression dressed as a
tidy-up. The X moves inside as "Remove from my contacts".

Closes #224.
2026-09-02 09:06:00 -07:00
Coffey Labs a61fe28523 Merge pull request #225 from Coffey-Labs/feat/skip-duplicate-uids-on-import
Skip events on re-import that the calendar already has
2026-09-02 08:57:36 -07:00
jcoffey-dev 74f6d1d0aa Skip events on re-import that the calendar already has
Importing an export twice left second copies of everything. The import has
kept the file's own UID since it was written -- inventing one only where an
event arrives without -- so what was needed to recognise an event that is
already here was there all along, and nothing looked at it.

Asked for on #173 after the reporter's colleague hit the duplication in
testing, and decided there: skip on a UID the calendar already holds, import
what arrives without one. An event with no UID is not one anything can match
to, and a softer match -- title and time, say -- guesses in both directions.

The UIDs are read once per import rather than once per event. CalendarEvent/
query does take a uid filter, which is what findByUid uses, but a file of two
thousand events would be two thousand queries. Read without expandRecurrences
so a weekly series is one event with one UID rather than one per occurrence,
and narrowed to the target calendar from calendarIds rather than through an
inCalendar filter this client has not confirmed the server supports.

Matching is per calendar. A UID is what makes an event the same event across
calendars, so the same event being in two of them is not a duplicate and the
second calendar still gets its copy.

importIcs now answers with both counts. "Imported 40 events" over a file of
240 reads as a failure when 200 of them were simply already there, and a
re-import of an unchanged file would otherwise report importing nothing at
all rather than saying everything was already here.

The three import toasts are translated in all nine catalogues while the
messages were being written -- the plural for the existing one had never been
added and was falling back to English.

Closes #222.
2026-09-02 08:52:33 -07:00
Coffey Labs f1a8f162cf Merge pull request #221 from Coffey-Labs/feat/set-folder-role
Set the Archive role from ihasmail, rather than describing it
2026-09-02 08:41:09 -07:00
jcoffey-dev 5e6e049eef Set the Archive role from ihasmail, rather than describing it
#220 corrected the message and left it useless: it told you a folder needs
the Archive role on the server, which was true, and gave you nothing to do
about it here. Roles were shown in Folders settings and never settable.

Mailbox/set takes `role`. Confirmed live against 0.16.20 on 2026-09-02, as an
ordinary user through the proxy, with no admin API: setting role "archive" on
a folder that had none returned updated and the folder began working as the
Archive immediately. Stalwart parses the role names in SpecialUse::parse,
"archive" among them, refuses a second holder of a role, and refuses to move
the role of Inbox, Junk or Trash.

So the toast now carries the fix. "No Archive folder is set yet." with a
Create one that makes the folder and then completes the archiving that could
not happen -- rather than leaving someone to select the same messages again.

A folder already named Archive and carrying no role is adopted rather than
duplicated. That is the state #217 was reported from, and a second Archive
beside the first would be its own confusion. One named Archive that is really
the Sent folder is left alone: taking its role to fix archiving would break
sending.

Folders settings gains a Role column. Archive, Drafts and Sent are offered,
being the roles this client's behaviour depends on and the server will move;
Inbox, Junk and Trash show theirs and cannot change it, because 0.16.20
refuses. A role another folder holds is left out of the list rather than
offered and refused, so freeing it is a deliberate two steps.

The folder is created with the server's own name, never the localised one,
for the reason renaming already writes back the server's: a German session
must not create "Archiv" that an English one cannot find.

Closes #217 properly.
2026-09-02 08:38:08 -07:00
Coffey Labs 8e9259638d Merge pull request #220 from Coffey-Labs/fix/archive-role-message
Say the Archive folder needs the role, not the name
2026-09-02 08:10:10 -07:00
jcoffey-dev 1611ae6918 Say the Archive folder needs the role, not the name
Archiving looks the folder up by its special-use role and by nothing else --
roleId("archive"), falling back to roleId("all") -- and then, finding
neither, told you to create a folder named "Archive". Naming a folder does
not give it a role, and ihasmail has no way to assign one: Folders settings
shows the role beside a folder and offers no way to set it. So the advice
sent someone round a loop that could not end. They make the folder, it still
does not work, and the message says the same thing again.

It now says what is actually required and where it lives: a folder needs the
Archive role on the server, and naming it "Archive" is not enough.

All nine catalogues carry the correction rather than falling back to English,
and they need the same native review the rest of them do.

The existing test asserted only that archiving complained. It now checks what
the complaint says, since the words were the whole bug.

Closes #217.
2026-09-02 08:00:46 -07:00
Coffey Labs 3f33b61369 Merge pull request #219 from Coffey-Labs/fix/unsaved-dialog-emphasis
Highlight saving, not discarding, on the unsaved-changes guard
2026-09-02 07:50:05 -07:00
Coffey Labs 6e1ac42d5a Merge pull request #218 from Coffey-Labs/fix/contact-import-batching
Set contact cards in batches the server will take
2026-09-02 07:50:00 -07:00
jcoffey-dev 4a99b77bc3 Highlight saving, not discarding, on the unsaved-changes guard
The guard shipped with "Discard changes" as the only choice carrying a
colour -- a filled red button, against a plain outlined "Save changes" --
which made losing the work the loudest thing in a dialog whose entire
purpose is to stop that. The emphasis belongs on the safe answer.

A dialog choice can now be marked `primary`, and Save is. Discard keeps its
`danger` flag, but a danger choice is drawn the way `.menu-item.danger`
already is: a red label on the ordinary surface. In a list of answers a
filled red button is not "this one is destructive", it is "this one is the
default", which is the opposite of what it meant here.

That rendering change reaches the other choice dialog too -- the calendar's
"this occurrence or the whole series", where both answers are marked danger
because both delete something. Two filled red buttons become two red labels
and nothing is highlighted, which is right: neither answer there is the safe
one, so neither should look like it.

Checked in the browser against the mock, in both themes. Light: #dc2626 on
white, 4.8:1. Dark: the theme's own --danger, which every palette already
tunes for contrast on this surface.

Reported on #175 by the reporter's colleague, who is right that the
non-destructive action is the one that normally gets the highlight.
2026-09-02 07:05:27 -07:00
jcoffey-dev 4121f9263b Set contact cards in batches the server will take
The same bug the calendar import had, in the three places contacts write
more than one card at once. ContactCard/set is refused whole over
maxObjectsInSet -- requestTooLarge, nothing created -- so a large enough
vCard or LDIF file imported nothing, and "select all, delete" over a large
address book deleted nothing and reported it in JMAP's words.

Nobody has hit it. It was found by looking, after #215 fixed the calendar,
and it is promised on #173.

Both imports now go through one createCards, which splits by the ceiling the
session advertises and falls back to 500. That is what the LDIF import's
comment -- "from ContactCard/set down they are the same" -- was already
claiming, and is now true of.

destroyCards splits the same way, and takes off the list the ids the server
said it destroyed rather than everything that was asked for. It removed all
of them before, which was harmless while there was one call and wrong the
moment a later batch can fail: deleted contacts must not stay on screen, and
live ones must not disappear from it.

One behaviour change beyond the batching. A vCard import the server accepted
no card of returned 0, and the view reported importing no contacts -- which
reads as an empty file rather than as a refusal. It now says why, which is
what the LDIF import has always done. A file with genuinely nothing in it
still says so, earlier and separately.
2026-09-02 06:54:24 -07:00
Coffey Labs 6b8ac393c9 Merge pull request #215 from Coffey-Labs/fix/ical-import-batching
Import an iCal file in batches the server will take
2026-09-02 06:43:20 -07:00
jcoffey-dev 17bd548524 Import an iCal file in batches the server will take
An 800 KB export imported nothing at all. Every event in the file went out
in a single CalendarEvent/set, and Stalwart refuses a method call carrying
more objects than maxObjectsInSet -- the whole call, with requestTooLarge,
creating none of it -- so the import failed at exactly the size that makes
importing worth doing. A two-event invitation was fine; a real calendar was
not.

The events now go out maxObjectsInSet at a time, which the client already
reads off the session and defaults to 500 where a server does not say. That
is the same ceiling and the same helper the mail store batches deletes and
flag changes by; nothing new had to be learned about the limit, and there is
no need to ask anyone to split an .ics by hand at an arbitrary line.

Still batches rather than a call per event: createEvent invalidates on the
way out and invalidating re-fetches every cached range, which is why the
import writes its own set calls in the first place. One invalidate, after
the last batch.

A batch that fails after earlier ones have been filed now says how many got
in -- "1000 of 1200 events were imported before this happened" -- and
re-reads the calendar so they are visible. Reporting only that the import
failed would send someone looking for events that are already there.

The mock enforced this ceiling all along, on both /get and /set; nothing had
exercised it with a file big enough to cross it.

Reported on #173.
2026-09-02 06:29:49 -07:00
Coffey Labs b0edce1d9b Merge pull request #214 from Coffey-Labs/fix/drag-day-across-timezones
Move an event by the days the hand moved it, not to the date dropped on
2026-09-02 01:11:53 -07:00
Coffey Labs 4570df6eab Merge pull request #213 from Coffey-Labs/fix/push-verification-cache-key
Name the push verification entry absolutely, from both sides
2026-09-02 01:11:32 -07:00
Coffey Labs 8962065782 Merge pull request #212 from Coffey-Labs/fix/attach-size-limits
Apply the upload limit only where something is uploaded
2026-09-02 01:11:11 -07:00
jcoffey-dev 7b3069e41b Move an event by the days the hand moved it, not to the date dropped on
Dragging an event across the month grid wrote the date of the cell it
landed on into the event's stored start. Those are the same date only
while the event's time zone is the reader's.

An event kept in Asia/Tokyo at 15:00 is drawn to a reader in Phoenix at
23:00 the previous evening. Dropped on the 11th, it was written as the
11th in Tokyo -- which is the 10th on screen. It went where its own
calendar said rather than where the pointer did, one day short, every
time.

Moving by the difference between the two local days instead moves it
exactly as far as the hand did, and adding whole days to a stored wall
clock leaves the time of day alone without touching the zone -- so the
frame the rest of this path is careful about is still not crossed.

Found by giving the mock an event in a zone that is not the machine's.
Every other fixture used the machine's own, which cannot tell a correct
conversion from no conversion at all: the case that works is the one the
fixtures were all testing.
2026-09-02 01:07:07 -07:00
jcoffey-dev 44b676c55d Name the push verification entry absolutely, from both sides
A JMAP push subscription stays silent until the client echoes back a
verification code. When the code arrives with no tab open, the service
worker leaves it in the cache for the next tab to collect.

Both sides named that entry relatively, and a relative key is resolved
against the URL of whoever is asking. The worker lives at <base>/sw.js, so
it wrote under <base>/; a tab at /mail/inbox/abc looked under
/mail/inbox/. They agreed only when the open page happened to be the root,
which is why this survived: the case that works is the one people try
first.

The failure is quiet in the worst way. A subscription that never gets its
code back simply never delivers, which is indistinguishable from push not
working at all -- there is no error anywhere to notice.

Both sides now build the key from the mount: the worker from the BASE it
already derives from its own location, the page through withBase. Found
while adding BASE_PATH, where the two disagree at every route rather than
only at deep ones; left alone then because it was pre-existing and
unrelated to that change.
2026-09-02 01:01:11 -07:00
jcoffey-dev 1b4788c0a7 Apply the upload limit only where something is uploaded
FEATURES has always said attach-from-Files works "however large", because a
blob the account already holds is attached by reference and nothing is
sent. The code checked every file against maxSizeUpload regardless, so the
two disagreed and the code was the one that was wrong.

maxSizeUpload is what the server will accept for a single upload (RFC
8620). It bears on a file that is about to be uploaded and on nothing
else. Applying it to a by-reference attachment refused a 60 MB message the
server was already storing, on the grounds that it could not have been
uploaded -- which it was not being. Forwarding a large message as an
attachment hit exactly that.

A file from somebody else's account is fetched and re-uploaded into this
one, because a message can only carry blobs from the account sending it.
That upload is real and the limit is real for it, so it still applies
there.
2026-09-02 00:58:37 -07:00
Coffey Labs 13a6bcd66b Merge pull request #211 from Coffey-Labs/feat/ical-subscriptions
Subscribe to a calendar published at a URL
2026-09-02 00:54:55 -07:00
jcoffey-dev 4d18d94b63 Subscribe to a calendar published at a URL
A timetable, a rota, a public holiday list: the calendars people are given
as a link, which ihasmail could not show at all.

Nothing is stored. The document is fetched when the calendar is opened and
parsed in the browser; the server keeps no copy, no cache and no schedule,
which is what lets an immutable container serve this. There is no timer
either -- there is nowhere to run one -- so the guarantee is that a
subscription is as current as the last time somebody looked, which is also
when it matters. That is said plainly rather than implied.

The fetch has to happen on the server: a calendar URL belongs to whoever
published it and almost none of them send CORS headers. That makes it the
second place this app knocks on a door somebody else chose, so the guard
the image proxy has always had was lifted out and both now call it. A
second SSRF implementation is how one of them ends up missing a case; this
way there is one, and the extraction is covered by the image proxy's own
tests still passing unchanged.

webcal: is understood, because that is how these are published, and it is
read as https: rather than waved past the checks -- a webcal URL pointing
at loopback is refused exactly like an http one.

Recurrence is deliberately not expanded. RRULE is a small language with a
lot of edge cases, and a subscription quietly showing the wrong dates would
be worse than one showing the first occurrence and saying so.

The parser is a subscription parser rather than an importer: a subscribed
calendar is read-only and redrawn from scratch each refresh, so nothing has
to round-trip or survive an edit, which is most of what makes a full
iCalendar implementation large. What it does have to do is never mis-state
a time -- a DATE is built in local time rather than at UTC midnight, which
would land on the day before for anyone west of Greenwich -- and never hang
on a document somebody else wrote.

Events go through instancesIn like the birthdays, so no view has to know
they are not real calendars, and the calendar they hang off reports no
write rights, so everything that asks before offering an edit declines on
its own. A subscription that cannot be read says so in the sidebar rather
than drawing an empty calendar, which looks like a calendar with nothing
in it.
2026-09-02 00:52:06 -07:00
Coffey Labs b4ee545856 Merge pull request #210 from Coffey-Labs/feat/drag-reschedule
Drag an event to move it, and its edge to resize it
2026-09-02 00:42:47 -07:00
jcoffey-dev a30f96f76b Drag an event to move it, and its edge to resize it
The calendar could only be edited through the editor, so moving a meeting
half an hour meant opening a dialog, changing two fields and saving. Every
other surface a finger or a pointer drives already answers to a drag.

In the day and week grids an event moves by dragging it and changes length
by dragging its bottom edge, snapping to fifteen minutes. In the month
grid it moves to another day and keeps the time it had, because a month
cell is a day and nothing finer -- changing the hour as well would answer
a question nobody asked.

It goes through the same path a menu edit takes. A recurring event is
asked which dates it means, and the answer runs through runScoped, so a
date the server will only change as part of a whole series offers that
rather than failing.

Three things do not offer a drag, and the reasons are checked in one place
so no grid has to remember all three: a read-only calendar, an event with
no calendar, and a birthday -- which is derived from a contact and has
nothing on the server to move. The reserved classes the swipe gesture was
told to keep out of are exactly the ones that are draggable here, which is
what that reservation was for.

Invitations are not sent. A drag is a scheduling gesture, and mailing
every guest on each nudge of a block is not what the hand was asking for;
a change that should go out with notice goes through the editor.

The new time is computed in the event's own frame rather than through an
instant. Working it out from the reader's local hours and then
re-expressing it in the event's zone converts twice, and the two do not
cancel: caught in the browser, where an event moved two hours the first
time it was dragged in the month grid and then sat still, because after
that its stored time and the reader's agreed. Parsing the stored string
into its parts and adding minutes to those touches no zone at all, and a
resize sends only a duration, so the question does not arise there either.
2026-09-02 00:40:03 -07:00
Coffey Labs 0ac5f78789 Merge pull request #209 from Coffey-Labs/feat/palettes
Six palettes, each with a light half and a dark one
2026-09-02 00:23:34 -07:00
jcoffey-dev c838569638 Merge branch 'main' into feat/palettes
# Conflicts:
#	web/src/store/settings.ts
2026-09-02 00:19:22 -07:00
Coffey Labs 72742ef30a Merge pull request #208 from Coffey-Labs/feat/list-sort-order
Let the message list be sorted by something other than the date
2026-09-02 00:18:27 -07:00
jcoffey-dev 9aa0eda0d5 Six palettes, each with a light half and a dark one
The theme was one enum -- system, light, dark, ihasmail -- where one value
carried a whole palette and implied dark. That works for exactly one
palette. The two questions now come apart: which palette, and which side.

Classic is the plain light and dark this app has always had. ihasmail's
own palette gains a day version, so the background of the dark one becomes
the text of the light one and the two read as one palette from either end.
Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own
projects, used under the MIT licence, and taken from each project's own
repository rather than from anyone's reimplementation. What was fetched is
recorded in .palette-sources/ and credited in NOTICE.

Giving ihasmail's palette a light half removed a whole special case.
Nothing is one-sided any more, so a palette can no longer override the
mode, the toggle no longer has to set a palette aside on the way to light
and remember it, and the greyed-out control that explained all that is
gone. The old lastDarkTheme, which existed only for that, is gone with it.

The shades between the published colours are derived rather than guessed:
these projects publish twelve to twenty values and ihasmail needs about
thirty. scripts/build-palettes.py computes the tiers and then measures
every text colour against the surface it sits on -- 4.5:1 for prose, 3:1
for borders and marks -- lifting anything short towards white on a dark
ground and towards black on a light one, so a lifted tier keeps its hue.
It refuses to write a palette that would not pass.

Every one of the nine halves needed at least one lift. These palettes are
built for code editors, not for prose at this size: Dracula's comment grey
is 3.03:1 on its own background and Rosé Pine's gold is 2.7:1 on Dawn.
Shipping them as published would have quietly ended the WCAG AA claim.

Two things caught while checking rather than while writing. The generated
blocks were appended to the end of the stylesheet, which put them after
the accent variants at equal specificity -- so choosing an accent over one
of the new palettes did nothing at all. They now sit before those rules,
where the existing ihasmail block's own comment says they have to. And
that block was unqualified, so it would have shadowed the new light half;
it is now explicitly the dark one.

Settings written before this carry `theme` and no palette, and are read
through the old enum. `theme` is still written back, derived, because a
device on an older build reads it and would otherwise be stranded on a
theme nobody chose.
2026-09-02 00:14:54 -07:00
jcoffey-dev 34fc5ab81f Let the message list be sorted by something other than the date
Newest-first was the only order, so the mail you had not read yet was
wherever it happened to fall.

Seven presets and up to three levels of your own. It covers the Inbox
alone by default: unread-first is what people want in the folder they
triage and confusing in Sent, where everything is read and the order that
matters is when it went. Search keeps newest-first whatever the setting
says, since a result list is already ordered by the question that was
asked.

The server does the sorting, over the whole folder, for the same reason
search runs there: a list sorted in the browser is sorted only as far as
the browser has loaded, which on a folder of ten thousand is the first
fifty and a lie about the rest.

Two details that are easy to get wrong and were worth pinning in tests.
hasKeyword sorts a boolean and false comes before true, so "unread first"
is $seen ASCENDING while "starred first" is $flagged DESCENDING -- the
other way round. Getting either backwards puts exactly the mail you were
looking for at the bottom. And every order ends with newest-first as a
tiebreak, because a sort whose last level is a keyword or a subject leaves
every tie undefined, and an undefined order changes between two looks at
the same folder for no reason the reader can see.

Sorting on a keyword is optional in RFC 8621, and a server that will not
do it fails the whole query rather than degrading it -- so this setting
could turn a folder into one that does not open. The refusal is caught
once, the keyword levels dropped and the query retried, and nothing is
said: the reader asked for an order and got the closest the server can
give, and a toast on every folder change would be the app complaining
about its own request.

The mock now honours the sort instead of always answering newest-first,
which had it reproducing a server that silently returns a different order
from the one asked for -- the one shape of wrongness a client cannot
detect. MOCK_NO_KEYWORD_SORT=1 reproduces a server that refuses the
keyword sorts, so the fallback can be developed against.
2026-09-01 23:51:56 -07:00
Coffey Labs 6c7c6d19b3 Merge pull request #206 from Coffey-Labs/feat/birthday-calendar
Show birthdays from the address book as a calendar
2026-09-01 23:35:46 -07:00
Coffey Labs c5be74fe57 Merge pull request #205 from Coffey-Labs/feat/tnef
Open winmail.dat
2026-09-01 23:35:26 -07:00
Coffey Labs 60d275f38b Merge pull request #204 from Coffey-Labs/feat/nested-labels
Nest labels, and let each one say how prominent it is
2026-09-01 23:35:04 -07:00
jcoffey-dev 1ed8531764 Line up selectedAll with the rest of the object it sits in
It came in at two spaces inside a six-space set({ ... }), which reads as
if it belonged to an outer scope. Whitespace only.
2026-09-01 23:31:28 -07:00
jcoffey-dev c9ab203b76 Show birthdays from the address book as a calendar
The dates were already on the contact cards and nothing ever showed them,
so the one thing a birthday is for -- noticing it in time -- was the one
thing the app could not do with it.

Derived, not stored. The dates stay on the cards: a second copy of the
same fact drifts the first time somebody corrects one, and keeping a
calendar of its own is exactly what ihasmail does not do. Entries are
generated when a view asks for a range and vanish when the contact does.
They go through instancesIn like everything else, so no view has to know
they are different.

Off until switched on. It is derived data, and a calendar that fills
itself with dates nobody put there is a surprise rather than a feature. It
can also be hidden from the calendar's own sidebar without being turned
off, which is the same distinction the shared calendars already draw.

They cannot be edited or deleted, and that falls out of the design rather
than being special-cased: the virtual calendar reports no write rights, so
every control that already asks before offering Edit or Delete declines on
its own. updateEvent and destroyEvent refuse a synthesised id as well, so
the store is safe whatever calls it -- including anything added later.

Two things about the dates themselves. A card that records only a day and
month is the common case rather than the exceptional one, and gets a
birthday with no age rather than no birthday. And 29 February falls on the
28th in a year that has no 29th: somebody born in February has a birthday
in February, and moving it into March is the arithmetic winning over the
fact. Both are conventions; these are the ones that keep the fact intact.

The mock now carries birthdays on most of its contacts, including one with
no year and one on 29 February, so both cases are visible without a real
address book.
2026-09-01 23:28:21 -07:00
jcoffey-dev be8b89f5ab Merge branch 'main' into feat/nested-labels
Both sides added a field next to the mail store's selection: the label
counts the sidebar draws, and the flag for a selection that means the
whole query rather than the loaded page. They are independent, so the
resolution keeps both.
2026-09-01 23:27:52 -07:00
Coffey Labs 9cace7c90c Merge pull request #203 from Coffey-Labs/feat/select-all-in-folder
Select a whole folder, not just the rows that are loaded
2026-09-01 23:24:14 -07:00
jcoffey-dev 9a474ef2c8 Open winmail.dat
Outlook sending in Rich Text packs every attachment into one TNEF blob.
Every other client shows a single unopenable winmail.dat, and the files
inside it are gone as far as the reader is concerned -- which is a
decoding problem rather than a mail one.

Written from the published format: a signature, a key, then a flat run of
attributes, each one a level byte, a 32-bit id carrying its own type, a
length, the data and a checksum. Attachments are delimited by
attAttachRenddata rather than named, which is why the parse is a small
state machine.

The MAPI property stream inside attAttachment is read for two properties:
the long filename and the MIME type. attAttachTitle carries an 8.3 name,
so a file that arrived as "Quarterly Report Final.docx" is QUARTE~1.DOC
there and correct here. The stream stops at a named property (id >=
0x8000) rather than guessing past it, since those carry a GUID before
their value and nothing after one can be trusted to stay aligned.

Decoded in the browser, on request. The server never sees the contents and
has nowhere to keep a decoded copy; doing the work on sight would spend
the bandwidth whether or not anybody wanted what is inside.

A blob that goes wrong part-way through keeps what was read before that
point, whether it ran out or the checksum stopped matching. Half the
attachments beats none: the alternative is a reader who can see the file
is there and cannot have it. The original stays attached either way.

The message body is deliberately not decoded. TNEF can also carry it as
compressed RTF, which is a second format again for a body the reader
already has in plain text or HTML nine times in ten.

The mock now sends one, built by its own encoder rather than by the
parser's fixtures, so the two are independent implementations of the same
description.
2026-09-01 23:16:22 -07:00
jcoffey-dev f7ef886b45 Nest labels, and let each one say how prominent it is
A flat list is fine at five labels and unreadable at thirty, and there was
no way to keep one that matters occasionally without it holding a row for
ever.

A label can now sit under another, and each says whether it belongs in the
sidebar always, only while it has unread mail, or never.

Nesting is display only. The keywords stay flat on the message, which is
what keeps them readable by every other client: moving a label under
another rewrites nothing in the mailbox, and a client that knows nothing
about ihasmail sees exactly what it always did. Both new fields are
optional, so a settings file written before this parses unchanged and
means what it did.

Settings sync between devices, so the tree has to survive shapes that
should not exist. A label whose parent was deleted on another device comes
back to the top level rather than vanishing -- a label that disappears
because something else was deleted is one the reader cannot get back. A
cycle arriving from an older device is broken by treating the label that
closes the loop as a root, so nothing is lost and nothing hangs. The
parent picker will not offer a label's own descendants, so one cannot be
built here in the first place.

A label kept by the unread rule keeps its ancestors, whatever they were
set to. A child cannot be drawn under a parent that is not there, and
promoting it to the top level would silently rearrange the tree at the
moment the reader is least able to explain why. The parent comes back as a
container instead, and its own count still says whether it has anything of
its own.

Unread counts come from one request carrying a query per label rather than
a request each, with limit 0 so the server does not send ids that would
only be thrown away. They refresh on the same beat as the folder counts,
since the things that move them are the same things, and a failure is
swallowed: a count is decoration, and the sidebar draws the label without
one.

Also corrects the Labels page, which said names and colours are kept in
this browser. They live in the account's own Files and follow it between
devices, like every other setting that is not about this screen.
2026-09-01 23:09:21 -07:00
jcoffey-dev 8bee0eb3c8 Select a whole folder, not just the rows that are loaded
The header checkbox selected the loaded page. On a folder of ten thousand
that is fifty of them, and the only way to act on the rest was to scroll
until they loaded and tick again.

A line now offers the rest by name once the page is selected, and taking
it is a separate press. A checkbox that silently meant ten thousand when
the screen shows fifty would be the worst of both, so each option says
what it actually covers.

The wider selection is a query rather than a list of ids. What it reaches
is resolved from the server when an action runs, walked a page at a time,
because a folder holds far more than one call returns and Email/set
refuses more ids than maxObjectsInSet in one go -- which setEmails already
chunks for. It resolves uncollapsed, unlike the list: "everything in this
folder" means every message rather than one per thread, and expanding
threads the way a click does is impossible here anyway, since that walks
loaded Email objects and these are the ones that were never loaded.

Two things this exposed.

Undo is now withheld once a move reaches messages that were never loaded.
It restores the folders each message was in, taken from what the browser
holds, and for an unloaded message that is nothing -- so the undo would
have written an empty mailboxIds and left the message in no folder at all,
which is worse than the move it was undoing. move() and archiveByDate()
both did this; both now drop the offer rather than restore something
wrong.

And an action consumes the wider selection. The optimistic paths cleared
the selected ids but not the flag, so the next action would have silently
reached the whole folder again.
2026-09-01 22:56:14 -07:00
Coffey Labs d300107be0 Merge pull request #202 from Coffey-Labs/feat/sender-link-warnings
Warn about outside senders, large sends and links that mislead
2026-09-01 22:48:12 -07:00
Coffey Labs 255616341b Merge pull request #201 from Coffey-Labs/feat/base-path
Serve ihasmail from a subpath
2026-09-01 22:47:53 -07:00
jcoffey-dev 5b18f1d5d7 Warn about outside senders, large sends and links that mislead
Four warnings, in Privacy & safety, and all of them start switched off.

That is not timidity. A client that begins by interrupting is one people
learn to click through, and a warning clicked through without reading
costs the same attention and buys nothing. The outside-sender warning
could not be on by default in any case: it measures against the domains
that count as yours, and with nothing configured every message in the
mailbox is from outside.

Your own identity domains are always internal and are not configuration.
An account signed in as [email protected] warning that example.com is
external would be absurd, and making it be typed in first is a foot-gun
that leaves the feature useless the moment it is enabled. Configured
domains are additional, and cover their subdomains -- matched on a dot
boundary, so example.com covers mail.example.com and not notexample.com,
which is exactly the domain somebody registers on purpose.

The four:

A banner names the sender's domain on a message from outside.

Sending outside names the outside recipients and asks, rather than
refusing. "This is going outside" is a rule and not something the sender
can check; a list of addresses is. It reads the full identity list rather
than the visible one, since hiding an identity from the From menu does not
make its domain somebody else's.

Sending to a large group asks once the count crosses a threshold, which is
what catches a reply-all onto a long thread. It counts people rather than
headers, so one address in To and nine in Cc is a message to ten.

Opening a link asks when the destination is not trusted, and always when
the link's text names one domain while its destination is another -- even
where that destination is trusted, because being trusted is not the same
as being the place the text claimed. On that mismatch the offer to trust
the domain is withheld: what would be trusted is the destination, and the
destination is not the thing in question. Anything that is not http or
https is left alone, since warning about a mailto: is noise and noise is
how a warning stops being read. Both bodies are covered, because a link in
a plain-text mail is linkified by us and points wherever it likes just as
readily as one the sender marked up.

The click is cancelled and the navigation re-issued after the answer,
since there is no way to hold a real navigation open across a dialog. The
reopen runs in the continuation of the dialog's own click, which is still
the gesture a popup blocker wants to see.
2026-09-01 22:45:22 -07:00
jcoffey-dev 93d0a32af2 Serve ihasmail from a subpath
`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.

That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.

Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.

Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.

Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.

Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.

Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.
2026-09-01 22:41:18 -07:00
Coffey Labs 4c54eb74f9 Merge pull request #200 from Coffey-Labs/feat/privacy-safety-settings
Gather the privacy settings into a section of their own
2026-09-01 22:36:58 -07:00
jcoffey-dev e4b82783c2 Merge branch 'main' into feat/privacy-safety-settings
# Conflicts:
#	web/src/styles/app.css
2026-09-01 22:34:36 -07:00
Coffey Labs ccc9f73a71 Merge pull request #197 from Coffey-Labs/feat/forward-as-eml
Forward a message as an attachment
2026-09-01 22:34:02 -07:00
jcoffey-dev 790213cc17 Merge branch 'main' into feat/forward-as-eml
# Conflicts:
#	web/src/store/compose.ts
#	web/src/views/mail/MessageView.tsx
2026-09-01 22:31:42 -07:00
Coffey Labs 1ea813940b Merge pull request #196 from Coffey-Labs/feat/spam-score-panel
Show what the spam filter said, in the message details
2026-09-01 22:31:07 -07:00
jcoffey-dev 97f8b34e8b Merge branch 'main' into feat/spam-score-panel
# Conflicts:
#	web/src/store/mail.ts
#	web/src/styles/app.css
2026-09-01 22:28:52 -07:00
Coffey Labs ec8e2ad2d0 Merge pull request #199 from Coffey-Labs/feat/calendar-swipe
Swipe the calendar sideways to step a day or a month
2026-09-01 22:27:51 -07:00
Coffey Labs 3c3ffbd425 Merge pull request #198 from Coffey-Labs/feat/archive-by-date
Archive into a dated folder
2026-09-01 22:27:32 -07:00
Coffey Labs 132d0b5205 Merge pull request #195 from Coffey-Labs/feat/template-placeholders
Fill placeholders when a template is inserted
2026-09-01 22:27:04 -07:00
jcoffey-dev b83de657d7 Gather the privacy settings into a section of their own
General had grown five unrelated headings and was where anything without
an obvious home ended up. Remote images were filed under "Reading", the
read-receipt policy under "Composing", the undo-send window beside the
default message format. They are the same kind of decision -- what reaches
a sender, and what asks before something happens -- and they were the
hardest settings in the app to find.

Privacy & safety now holds all six, in three groups: remote content, read
receipts, and the things that ask before it is too late. General keeps
what it is actually about and is thirty lines shorter.

The line against Security & sessions is worth stating, because two similar
words next to each other in a nav is how a menu becomes something people
hunt through. Security & sessions is credentials and access: password,
two-factor state, app passwords, live sessions. Privacy & safety is how
the app behaves towards the reader and towards senders.

Nothing moved in storage. Settings are a flat object in settings.json and
sections are only how they are grouped on screen, so this is a UI change
with no migration and no key renames.

Two things beyond the move.

The senders trusted with remote images are now listed and can be withdrawn
one at a time. A sender was added from a message and could then only be
removed by finding another message from that same sender, which is not a
way to review a list you cannot see.

And General's lead said settings are stored in this browser, which is only
true when the server has no FileNode support. They normally live in the
account's own Files and follow it between devices, so the sentence
contradicted the feature it sat above.
2026-09-01 22:24:23 -07:00
jcoffey-dev 7d9a01cb93 Swipe the calendar sideways to step a day or a month
The calendar had next and previous as toolbar buttons and n/p, and nothing
for a thumb. Every other surface in the app that a finger drives already
answers to a swipe.

Day and month only. Those are the two views where a period is a page;
week and agenda scroll through a range rather than turning to the next
one, so there is nothing a sideways flick would obviously mean. Dragging
left pulls the next period in from the right, the way paper and every
phone do it.

Three things it deliberately does not do.

It draws nothing while the finger moves. The row swipe slides the row open
because the strip underneath has to name which of six actions is about to
happen; stepping a calendar has two outcomes and the direction of the
finger already says which. Translating the grid would also break the
sticky day header, since a transform makes a containing block. The
threshold is reported by the vibration motor instead, which is what the
haptics are for.

It does not start on an event. Which gesture was meant is decidable at the
moment the finger lands and only then, so dragging an event stays
available to be built later without having to be untangled from this
first.

And it asks for a longer drag than a row swipe -- not because the
consequence is bigger, since stepping back undoes it while a swiped row
has already been archived, but because this gesture has no way to change
its mind. A row reveals what it will do and can be let go early, and
offers Undo after. This shows nothing and offers nothing, so the distance
is the only chance to not mean it.

The axis lock is the shared one, keeping its bias towards the vertical:
the day grid scrolls through the hours, and a scroll misread as a swipe
throws the reader into another day. The toolbar buttons and n/p stay,
because a gesture with no visible control is one only the people who
already know about it can use.
2026-09-01 22:13:32 -07:00
jcoffey-dev 785570a41d Archive into a dated folder
Archiving put everything in one folder, so an Archive that has been
collecting for years is a single flat list with no way to narrow it except
search.

Archive by year and Archive by month file into Archive/2026 and
Archive/2026/09, creating the folders as needed and reusing them after
that, including folders made by hand or by another client.

The names are numeric and zero-padded rather than month names, because
these are real server-side mailboxes rather than anything of ihasmail's.
Every other client sees them: a folder created as "September" by someone
reading in English stays "September" for the same account read in
Japanese, since the name is stored and not translated. And 09 sorts
between 08 and 10 where a name does not.

The date is read in the reader's own timezone rather than UTC so it agrees
with the date shown against the message in the list. A message that
arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
filing it under 09 while the list says August would be the app
disagreeing with itself. A message whose date cannot be read goes to
Archive itself rather than to a folder named after a guess.

A selection spanning two months is two destinations, not one. The moves
are made silently and one toast names where everything went -- the folder
where there is a single answer, the count where there is not -- because
each group raising its own toast with its own Undo would mean undoing a
third of a move. One Undo restores the whole selection to wherever each
message came from, captured before anything moved.

The menu labels name the destination where there is one, so it reads
"Archive to 2026/09" rather than describing the rule, and falls back to
"Archive by month" for a selection with no single answer.
2026-09-01 22:06:33 -07:00
jcoffey-dev 0db795371e Forward a message as an attachment
Forwarding quoted the original into a new message, which is the right
thing for passing on something to be read and the wrong thing for passing
on something to be looked at. Quoting rewrites the body, drops the
headers, and re-parents the attachments, so a bounce, a phishing report or
anything else where the message itself is the evidence arrived altered.

Forward as attachment sends the message whole, as a message/rfc822 part.
It costs no upload at all: a message's own blobId is its RFC822 blob and
already lives in the account, so this goes through the same by-reference
path as attach-from-Files and a 40 MB message attaches as fast as a small
one.

It is in the message's own menu, the list's right-click menu, and the
overflow on the reply strip at the foot of a thread, which is the one a
thumb finds on a phone.

Two things fixed on the way, both exposed rather than introduced by this.

The filename rule was subject.replace(/[^\w.-]+/g, "_"), and \w without
the u flag is ASCII: every character of a Russian, Japanese or Chinese
subject failed the class, so those messages downloaded as a row of
underscores. What is actually unsafe in a filename is much shorter than
"not ASCII" -- path separators, the names Windows reserves, the control
range -- so the rule now keeps letters from any script and drops only
those. It lives in one place and the .eml download uses it too.

And the composer's attachment chip set overflow/text-overflow on a span,
where neither does anything, so the name never truncated and the size ran
on after it on the same line. Only long names showed it, which is every
.eml named from a subject.
2026-09-01 21:57:11 -07:00
jcoffey-dev 63c2839602 Show what the spam filter said, in the message details
The filter in front of the mailbox scores every delivered message and
writes its working into headers, and none of it was being read. A message
in Junk gave no reason for being there.

Nothing here scores anything. The headers are parsed and shown, so this
cannot disagree with the filter that actually made the decision.

Two formats cover what sits in front of a Stalwart mailbox in practice:
the SpamAssassin-shaped X-Spam-* set, which Stalwart's own filter writes,
and Rspamd's X-Spamd-Result. A header in neither shape is left unread
rather than guessed at, since a misparsed score shown confidently is worse
than no panel at all. Mail that arrived without any of them shows nothing.

Rules are listed largest mover first and signed, because which way a rule
pushed is the point, and the biggest contributor is the answer to why the
message scored what it did.

Two things it deliberately will not do. A score is always given the
threshold it was measured against, because 6.7 is damning against 5 and
unremarkable against 15 -- the number alone is not something a reader can
act on; where no threshold was stated, it says so rather than implying
one. And where the filter recorded no verdict, none is derived from score
against threshold: the filter applies policy we cannot see, and putting a
verdict in its mouth would be inventing one.

The mock writes the same headers at delivery -- spam in Junk, clean in the
Inbox, nothing on mail this account wrote -- so the panel can be developed
and demoed against it.
2026-09-01 21:45:22 -07:00
jcoffey-dev c85525f3ed Fill placeholders when a template is inserted
Templates were a fixed subject and body, so anything that changed per
message -- who it is going to, today's date -- had to be typed over
afterwards.

Eight names are recognised: recipientName, recipientFirstName,
recipientEmail, myName, myEmail, subject, date and time. Dates and times
go through datetime.ts rather than toLocaleDateString, so a template
follows the date order and clock the app was already told to use.

Filling happens on insert rather than on send. What a placeholder came to
is then visible in the composer and can be edited, instead of the message
changing between writing it and sending it.

Two things are deliberately left alone. A placeholder that cannot be
answered yet -- a recipient's name on a draft nobody has addressed -- stays
in the body as written, because substituting an empty string produces
"Hi ,", which is wrong rather than visibly unfinished; leaving the name
says which word is still missing and can be typed over. And a name that is
not a placeholder is left as written too, since a body that quietly ate an
unrecognised token would be worse than one that shows it.

Values are escaped on the way into HTML: a display name comes from a
contact card or a typed address and is not trusted markup.
2026-09-01 21:30:18 -07:00
Coffey Labs 34578ba426 Merge pull request #194 from Coffey-Labs/feat/files-multiselect
Select more than one file at a time
2026-09-01 21:14:20 -07:00
jcoffey-dev 00b580bad8 Select more than one file at a time
Moving or deleting five files meant doing it five times, each with its own
confirm. Rows now select the way they do in a file manager: a plain click
replaces the selection, ctrl or cmd adds and removes one, shift takes the
run from the last row clicked, clicking past the last row or pressing
Escape clears it. Two or more selected raises a bar with Move to... and
Delete, and the row menu offers the same for the whole selection.

The move is one `FileNode/set` rather than a loop, and not only for the
round trip: a loop would apply half the moves and then throw, leaving a
selection split across two folders with nothing saying which half went.
One call is one answer, and `notUpdated` names whatever the server
refused.

Right-clicking inside the selection acts on all of it; right-clicking
outside means you meant that row, so the selection follows the pointer
rather than the menu quietly applying to something off-screen. A drag
carries the whole selection the same way, which is why the payload is now
a list -- and why a drop is refused unless every file in it can land,
since a drag that moves four of five and skips the fifth is worse than
one that will not start.

A selection belongs to the folder it was made in, so changing folder or
account drops it: rows left selected off-screen make the delete two
folders later a surprise.
2026-09-01 21:11:36 -07:00
Coffey Labs b5ca0021ef Merge pull request #193 from Coffey-Labs/feat/edit-text-files
Edit a text file where you are already reading it
2026-09-01 20:54:14 -07:00
jcoffey-dev a4c0e04ab9 Edit a text file where you are already reading it
v2 of the viewer: Edit, on text and Markdown, in the dialog and on the
row menu. Save is explicit -- every save mints a new blob, so autosave
would burn quota and multiply the conflicts it cannot see.

Two people editing one file is the case worth getting right. `saveText`
re-reads the node and compares the blob the editor started from: if
somebody else saved in the meantime it refuses, says so, and leaves the
work in the box to copy out. `ifInState` is the obvious tool and the
wrong one -- it is the state of every FileNode in the account, so an
unrelated upload in another folder would fail the save, and a warning
that cries wolf is a warning people click through.

Editing is not offered where saving would lose something: a file
truncated for display would have its tail written away, and one that did
not decode as UTF-8 would have mojibake written over whatever encoding it
really is. Both open read-only and say which. Nor is it offered without
mayModifyContent -- a read-only share just has no Edit.

Closing or cancelling with unsaved changes asks first, Ctrl+S saves, and
mail attachments are unaffected: they pass no onSave, because a message
part is not a thing that can be written back.
2026-09-01 20:51:51 -07:00
Coffey Labs 8d562628ff Merge pull request #192 from Coffey-Labs/feat/markdown-render
Read a Markdown file as the document it is
2026-09-01 20:36:40 -07:00
jcoffey-dev 15f2c3d357 Read a Markdown file as the document it is
A .md previewed as its own source, which is reading the punctuation
rather than the notes. It now opens rendered, with Rendered | Source in
the dialog footer for anyone who wants what the file actually says.
Markdown only; a .txt has nothing to toggle between.

Rendering is `marked`, sanitised by DOMPurify -- the one the app already
carries for mail. Markdown is not a safe subset of anything: raw HTML
passes through it by design, so a <script> in a file somebody uploaded or
shared into the account is a script tag unless something takes it out.

Images become links rather than pictures. An image in a Markdown file is
either a relative path, which has no base to resolve against here, or a
URL somewhere else, which fetches on open and tells that server the file
was read -- the tracking pixel this app blocks in mail. The link keeps
the alt text and the address, so nothing vanishes silently.

Fixes the PDF preview while here, which never worked: securityHeaders
put X-Frame-Options: DENY on every response including the blob route, so
the iframe showed Chrome's "refused to connect" where the file should
have been -- in Files today and in mail attachments long before that.
The middleware now leaves a header the route has set, and a PDF served
inline says SAMEORIGIN. Nothing else on the server is framable.
2026-09-01 20:32:59 -07:00
Coffey Labs c170b8554c Merge pull request #191 from Coffey-Labs/feat/file-preview
Look at a file without downloading it first
2026-09-01 20:21:48 -07:00
jcoffey-dev 984f0474e3 Look at a file without downloading it first
Files could only hand you the bytes: double-clicking a picture put it on
disk and left you to find it. The viewer for this already existed --
images, PDFs and text, in the attachment preview in MessageView -- it was
just wired to one screen. It is now a component both screens use.

Two things it needed before it was any use on Files. The type detection
falls back to the file name: an upload carries whatever the browser
guessed, which for anything unusual is application/octet-stream, so the
old exact-type check saw nothing to show in a .md that had just been
uploaded. And text is read with fetch, which ignores Content-Disposition,
so Markdown previews even though the server will not serve it inline.

Whether we can show a file and whether the server will serve it inline
are separate questions, and lib/preview.ts answers them separately:
`openableInTab` mirrors the isInlineSafe allowlist in the blob route,
because navigating to a blob the server will not inline just starts a
download. SVG is left out of both -- it carries script, and how to show
one safely is its own question, not a detail of a file lister.

Printing goes with it. A picture or a text file prints from the dialog
with everything else dropped; a PDF prints itself from its own iframe,
since the page around it cannot paginate someone else's document. Hiding
`.app` alone was not enough there -- `#root` kept its height and printed a
blank first page, the same trap as the message card.
2026-09-01 20:18:51 -07:00
Coffey Labs 49f9a06009 Merge pull request #190 from Coffey-Labs/fix/print-single-message
Print the message you asked to print
2026-09-01 19:49:13 -07:00
jcoffey-dev a33260966c Print the message you asked to print
The Print in a message's own menu called window.print() bare, so it
printed the whole conversation -- every message on the page -- which is
what the toolbar's "Print conversation" is already for. Opening one
message's menu and asking to print it is not a request for the other
eleven.

It now marks the card it was opened from and the print stylesheet drops
the siblings for the duration. The subject heading stays: a printed
message with no subject on it is a page nobody can file. The "3 messages"
count beside it goes, since only one of them is on the paper.

The label is left as "Print" rather than made more specific -- it is
translated in all nine shipped catalogues, and the toolbar entry beside
it already says "conversation".
2026-09-01 19:46:44 -07:00
Coffey Labs a16faa1789 Merge pull request #189 from Coffey-Labs/fix/print-light-and-first-page
Print on white, and start on page one
2026-09-01 19:39:49 -07:00
jcoffey-dev 0fb7fa09f6 Print on white, and start on page one
Printing carried whatever theme was on screen: a dark reader printed a
dark sheet, message body included -- that renders in a shadow root, and
follows the app palette through inherited custom properties, so no rule
in this stylesheet could reach it. The print block now pins the palette
tokens themselves to a light, unpainted set, which reaches the body the
same way the theme does. Backgrounds go white rather than the light
theme's greys; a printer should not lay ink over the whole page.

Page one was also blank apart from the subject. `break-inside: avoid` on
the message card cannot be honoured by a message taller than a sheet,
and Chrome answers by moving the card to a fresh page and breaking it
there anyway. Only the header is indivisible now, kept with the body
that follows it.
2026-09-01 19:37:23 -07:00
Coffey Labs 4912ea8ef4 Merge pull request #188 from Coffey-Labs/fix/ctrl-click-selection
Take the message you were reading into the selection
2026-09-01 13:44:27 -07:00
jcoffey-dev f25b559cb5 Take the message you were reading into the selection
Ctrl-clicking a second message selected only the second. The first stayed
highlighted, because it was the one open -- which is a different state
wearing a similar colour -- and was never actually selected. So both looked
picked, one was, and every action that followed applied to half of what the
screen showed, silently.

The cause is visible once the two rules sit together: shift-click took the
whole run *including* the row it started from, and ctrl-click took only the
row clicked. Two branches of one handler that had drifted apart, with
nothing asserting they agreed.

So they are one function now, and tested. Ctrl-click brings the current row
with it while nothing is selected yet; once there is a selection it toggles
exactly one row, which is what it is for. Shift-click is unchanged, and
keeps its anchor where it is so extending a range twice grows it from the
same place rather than from wherever it last reached.

The last test asserts the property that failed rather than the branches:
whichever modifier begins a selection, the anchor is in it.
2026-09-01 13:41:04 -07:00
Coffey Labs fa55636fb0 Merge pull request #187 from Coffey-Labs/docs/demo-link
Point at the demo from the top of the README
2026-09-01 13:04:17 -07:00
jcoffey-dev 8116cd0393 Point at the demo from the top of the README
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.
2026-09-01 13:00:43 -07:00
Coffey Labs bbdceb2697 Merge pull request #185 from Coffey-Labs/docs/scheduling-and-filters
Write down what the last week's features actually do
2026-09-01 11:03:25 -07:00
jcoffey-dev 0871d3291b Write down what the last week's features actually do
FEATURES.md had nothing on three of them. The scheduling panel and what it
will not show, the filter editors no longer discarding work in silence,
and where compose-as-new can be reached from — including the reply strip,
which is the one a thumb finds.

KNOWN-ISSUES.md gains the free/busy finding, which is the sort of thing
that page exists for: it was checked against the live server rather than
assumed, free/busy between accounts turns out to need no sharing set up,
and a principal offers no route to its calendars at all. Also what the
check did *not* settle, and which way ihasmail errs in the meantime.

ROADMAP.md gains the two things left deliberately unbuilt: a scheduling
view you can visit with no event in hand, and per-message actions from the
message list on a touchscreen, where the gesture that would open them
already means "select".
2026-09-01 11:00:04 -07:00
Coffey Labs 2beedc0f3c Merge pull request #184 from Coffey-Labs/fix/compose-as-new-on-mobile
Put "compose as new" where a thumb can find it
2026-09-01 10:56:32 -07:00
jcoffey-dev e380e168cd Put "compose as new" where a thumb can find it
It was never absent on a phone: it sits in the menu behind the ⋮ at the
top of a message card. But that is not where anybody looks. On a phone you
act on a thread from the strip at the bottom -- Reply, Reply all, Forward
-- and what is not there is, for practical purposes, not there.

The strip gets an overflow of its own, with compose-as-new in it. A fourth
labelled button does not fit; this does, and it says what it is once
opened.

Measuring to place it turned up something else. Three labelled buttons
want about 390px, and with the overflow rather more: enough for a 430px
phone and not for a 390, 360 or 320 one. The strip was already over that
line on the smaller ones before today, and simply overflowed. It now
wraps, and the spacer that would push the overflow onto a line of its own
is dropped on narrow screens so the buttons wrap as a group.

Closes #181
2026-09-01 10:53:28 -07:00
Coffey Labs 0c1820e2bb Merge pull request #183 from Coffey-Labs/feat/scheduling-panel
Schedule against the whole guest list, and say who cannot be read
2026-09-01 10:46:57 -07:00
jcoffey-dev e1402e472f Schedule against the whole guest list, and say who cannot be read
The availability bar showed the guests it had free/busy for and quietly
left out the ones it did not. In one row that is survivable. As a grid it
would be a lie: a row with nothing in it reads as a diary with nothing in
it, and "we cannot see this person's calendar" is the one thing that must
not look like "this person is free".

So everyone the event concerns now gets a row, and the ones with no
free/busy to read are drawn hatched rather than empty, with a line under
the grid saying how many and why. You get a row too, first. Scheduling
around the other people and not around yourself is how two things end up
at the same time, and the organiser was the one calendar the panel never
showed.

Your own row is never unknown. Where the directory does not list you under
the address your identity sends from -- an alias, a login that differs
from the address -- the account is still yours to read, and Stalwart
answers for it under the account's own id.

The window steps backwards and forwards a screenful at a time without
touching the event, which is the "movable forwards & backwards" the report
asks for, and offers its way back when you have wandered off.

And the bars are somewhere to put the event rather than only something to
read: the pointer shows the half hour it is over, and a click moves the
event there keeping its length. Clicking while stepped away moves the
event to where you clicked and returns the view to it, so it lands where
you were looking instead of jumping.

Free/busy is answered per principal, and only the server's own accounts
are principals. Somebody at another domain has none to read -- which is
not a gap to be closed, it is what the protocol can see -- so the grid
says so rather than drawing them blank.

Closes #172
2026-09-01 10:05:50 -07:00
Coffey Labs fb1ad3c5c8 Merge pull request #182 from Coffey-Labs/feat/availability-labels-multiday
Say what the availability bar is showing, and show all of it
2026-09-01 09:58:25 -07:00
jcoffey-dev c4649e0084 Say what the availability bar is showing, and show all of it
The bar was a day wide whatever it was drawing. It began at midnight on
the event's start day and stopped 24 hours later, so an event running over
two days showed availability for the first of them and gave no sign there
was more. And it carried no marks at all, which left "is this the whole
day or only working hours" unanswerable without dragging the event about
to see where its own outline moved.

It now covers whole days from the day the event starts to the day it ends,
and the free/busy lookup asks for the same range it draws. Above the bars
is an axis: hours every three across a single day, every six across two,
day names beyond that. The marks are drawn down the bars too, so a busy
block can be read against the hour it starts at rather than guessed at.

Whole days, always. A bar starting at the event's own start time would
move under the reader every time they adjusted it, and "busy from about a
third of the way along" is not a time anybody can read.

A week is as far as it goes. Something running longer is not an event
anybody is hunting a free slot in, and a month at eight pixels a day would
say nothing; it says how many days it left out instead.

The span is measured between two real midnights rather than counted in
24-hour days, because twice a year they differ, and every position on the
bar is a fraction of it.

The mock answered with a single busy block on the first day whatever range
it was asked for -- all a day-wide bar could show -- which would have left
a multi-day bar looking like everyone was free from the second day on. It
now answers across the range.

This is parts 1 and 2 of #172. The separate multi-day scheduling view it
also asks for is still open, and needs an answer first on what to show for
participants who have no free/busy to read.
2026-09-01 09:43:41 -07:00
Coffey Labs df9349935d Merge pull request #180 from Coffey-Labs/feat/ldif-import
Import an address book in LDIF
2026-09-01 09:31:18 -07:00
jcoffey-dev fc0e2b2b3e Import an address book in LDIF
Somebody arriving from SOGo, Thunderbird or an LDAP directory has their
contacts in LDIF, and until now the only way in was vCard.

Nothing on the server reads LDIF, so this reads it here, in two pieces
that are two different problems. `ldif.ts` is RFC 2849 and nothing else:
folded lines, base64 values, case-insensitive attribute names, options,
comments, `version:` headers, change records. It knows no attribute by
name. `mozillaAb.ts` knows the attributes and no syntax -- Mozilla's
address book schema, which is what Thunderbird and SOGo write and what the
issue asks for by name. LDIF says nothing about what any attribute means,
so a file is only readable against a schema, and keeping the two apart is
what would let a second schema be added without touching the reader.

Work and home addresses, which the schema keeps in two separate sets of
attributes, come across as two addresses. So do every phone kind, the
second email, the organisation and its units, job title, nickname, web
pages and the AIM handle. The four custom fields have no equivalent in
JSContact and are appended to the note, labelled as Thunderbird labels
them: keeping something somebody chose to write down is worth more than
the tidiness of dropping it.

An entry with neither a name nor an address is skipped rather than
imported as a blank row that is impossible to identify and tedious to find
again to delete. The distinguished name is not used as the contact's uid:
it says where an entry sat in somebody else's directory.

One import control takes either format and decides by what is in the file
rather than by what it is called, because an address book exported as LDIF
arrives as .ldif, .ldi, .txt or with no extension at all.

Closes #174
2026-09-01 09:27:07 -07:00
Coffey Labs 5a7acb7306 Merge pull request #179 from Coffey-Labs/feat/compose-as-new
Send a message again as a new one
2026-09-01 09:10:42 -07:00
jcoffey-dev 02c0332d8f Send a message again as a new one
A mail the far end rejected, or one that went to an address with a typo in
it, is a mail you want to send again -- not forward, and not reply to.
Doing it by hand meant a new message and copying five fields across.

"Compose as new" sits with Reply and Forward, in the message menu and in
the list's right-click menu. It opens a composer holding the recipients
the message had, its Reply-To, its subject with nothing prefixed to it,
its body with nothing wrapped around it, and its attachments.

What makes it a new mail is what it leaves behind. `draftId` stays null,
or sending would destroy the message it was made from. `inReplyTo`,
`references`, `relatedEmailId` and `relatedKeyword` stay null, so it hangs
off no thread and sending it marks the original neither answered nor
forwarded. The Message-ID is the server's and the date is stamped at
build time, so both are new without anything asking for them -- the send
path needed no changes at all.

A message you sent is composed as the identity you sent it as. One
somebody else sent has no identity of yours to match, and guessing from
whom it was addressed to would put the resend behind an alias that was
only ever the receiving end, so that case takes the account's default.

No signature is added. The body is the one that was sent, which already
ends in whatever signature went with it, and appending the identity's
would give it two.

Closes #176
2026-09-01 08:58:15 -07:00
Coffey Labs 53513e6744 Merge pull request #178 from Coffey-Labs/feat/ical-import
Import an iCal file into a calendar
2026-09-01 08:43:37 -07:00
jcoffey-dev 6431ec87f5 Import an iCal file into a calendar
An .ics reaches you by ways that are not your mailbox -- a ticketing
system a customer invited, a colleague's export, a booking confirmation
forwarded on -- and until now the only events ihasmail could take were the
ones attached to a message it had received.

The calendar's own menu now offers "Import iCAL file…", which files
everything in the file into that calendar. No global button: the issue is
right that this is not a frequent enough thing to earn one.

The parsing is the server's, through the same CalendarEvent/parse an
emailed invitation already goes through. An .ics is not a format worth
reimplementing in a browser, and Stalwart's reader handles what a
hand-rolled one would not.

Every event goes out in a single CalendarEvent/set. The round trips are
the smaller half of the reason: createEvent invalidates on the way out and
invalidating refetches every cached range, so a year of events imported
one at a time would refetch the calendar a few hundred times.

Nothing is mailed to anyone named in the file. Importing is filing
something you already have, and scheduling messages would be a surprise to
its participants.

The mock's parser read the whole file with one regex and returned one
event, which is all an invitation ever needed. It now reads per VEVENT, so
a multi-event file can be tested against it, and it invents an organiser
and an attendee only for events that carry a METHOD -- a plain export is
not addressed to anyone.

Closes #173
2026-09-01 08:38:06 -07:00
Coffey Labs 1a29784848 Merge pull request #177 from Coffey-Labs/fix/sieve-unsaved-changes
Ask before the filter editors lose your changes
2026-09-01 08:13:26 -07:00
jcoffey-dev d3e173c9c1 Ask before the filter editors lose your changes
Both editors on the Filters & rules page kept their edits in component
state, so every way out of the page threw them away without a word: a
settings link, the app rail, even the Rules/Scripts switch. The only sign
there had been anything to lose was a Save button that a screenful of
rules had already pushed below the fold.

Editors now register what they have pending, and every in-app navigation
asks first -- offering to save, rather than making "leave without saving"
the easy answer and saving the one you have to back out and find. Wouter
routes links, redirects and navigate() through one place, so the guard
holds for the app rail and the settings nav without either knowing an
editor exists. The Rules/Scripts switch asks for itself, since it never
reaches the router. Reload and tab close get the browser's own prompt.

The save bar is pinned to the foot of the pane, so "Unsaved changes" is
on screen whether or not the rules fit in the window.

Two things fixed on the way past, both in the raw script editor: saving
cleared only the selection, which left the editor open with the name
unlocked so a second save created a duplicate script instead of updating
the one just written; and the pair of identical nested conditions that
decided whether the editor was open at all is now the one question it was
asking twice.

Fixes #175
2026-09-01 07:05:17 -07:00
Coffey Labs b63f53c55b Merge pull request #170 from Coffey-Labs/docs/create-event
Say in the README that a message can become an event
2026-08-31 23:19:30 -07:00
jcoffey-dev 1e9d7bb596 Say in the README that a message can become an event
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.
2026-08-31 23:17:03 -07:00
Coffey Labs 2dd0203ae1 Merge pull request #169 from Coffey-Labs/feat/event-guests-from-message
Invite the people the message was already between
2026-08-31 23:09:38 -07:00
jcoffey-dev b862f61cde Invite the people the message was already between
Follow-up to #167: an event made from a mail now opens with the sender
and everyone it was addressed to already in the guest list, so a thread
becomes a meeting without retyping the room.

Two things are deliberately left out. The reader's own addresses, since
they are the organiser and an organiser among their own guests is an
invitation to your own appointment. And a blind copy, on a message the
reader sent: a guest list is visible to every guest, so promoting a Bcc
to a guest would tell the room about a copy the sender chose to hide.
That is not something a menu item may do quietly.

*Send invitation emails to guests* now starts off when the guests were
inherited rather than typed, and on everywhere else -- which is every
event whose guests somebody chose one at a time. The reason is the case
the issue opened with: a reminder made out of a bill carries the biller
and everyone else on the mail. Left on, the primary button reads Send
invites and the first press mails all of them an invitation to what was
meant as a note to self. The switch sits right there under the list and
says what it does, so inviting them is one deliberate click. Un-sending
is not.
2026-08-31 23:07:18 -07:00
Coffey Labs 16308431b8 Merge pull request #168 from Coffey-Labs/feat/create-appointment-from-email
Make an event out of a message
2026-08-31 22:57:04 -07:00
jcoffey-dev 7ba749148f Make an event out of a message
Asked for in #167: a right-click on a mail that turns it into a calendar
entry, the way a bill or a task becomes a reminder. Nothing clever, and
deliberately so -- the subject becomes the title, the body becomes the
description, and the reader supplies the one thing the message cannot.

A due date is exactly that thing. "Due on the 14th" in an invoice is not
a date a parser could be trusted with, and a wrong guess quietly
scheduled is worse than no guess at all, so the editor opens on the next
half hour for an hour and the reader fixes it. Forward rather than now,
because a start time that has already passed by the time they press
Create is one more thing to correct.

The body is capped at 5000 characters. A newsletter is a message too,
and its whole body would be stored on the event, synced to every device,
and shown in a three-row textarea; what is worth keeping -- the amount,
the account, the address -- is near the top. The cut is marked, so a
truncated bill is not read as the whole of it.

One message only. The list menu acts on the selection everywhere else,
but there is no sensible event to make out of five mails, and the mobile
entry appears only when exactly one row is held.

The editor lives inside CalendarView and the reader is in the mail view
when they ask, so the draft waits in the calendar store until that view
mounts and takes it -- once, or it would reopen on every later visit.
It seeds a form rather than an event: the dialog still says New event
and still has to be pressed.

Called *Create event…* rather than "appointment", which is the word the
issue used: it opens the New event dialog, and each catalogue already
has its own settled noun for that -- Termin, événement, 日程.

Reachable three ways, since a phone has no right-click: the row context
menu, a message's ⋮, and the ⋮ of a held row on mobile. Hidden entirely
where the account has no calendar.
2026-08-31 22:52:59 -07:00
Coffey Labs 3517d48a90 Merge pull request #166 from Coffey-Labs/mobile-drawer-alignment-and-close
Hang every folder off one edge, and give the drawer a way out
2026-08-31 22:10:33 -07:00
jcoffey-dev 94639e8420 Hang every folder off one edge, and give the drawer a way out
Two things the drill-down got wrong, both found on a phone-width window.

The folders did not line up. The rule that drops the twisty's 30px
gutter was hung on the rows offering a drill, so only folders with
children lost it -- they sat 18px left of every folder without any, and
the column of icons came apart. Whether a folder has children is not a
reason to hang it somewhere else. The class moves to the list, which is
what the indent is a property of; icons now share one column and labels
another, at every level and on the back row too.

There was no obvious way back out of the drawer. It covers the top bar
-- it is taller than it -- so the hamburger that opened it is
underneath, and pressing the same place again did nothing at all, since
that handler only ever set the drawer open. The dimmed strip beside the
drawer was the only exit, and nothing says so. There is now a close
where the hamburger was, moved by the same rule so it lands on exactly
the same pixels, and the hamburger itself toggles rather than only
opening. Escape closes it too, for a tablet with a keyboard.

Raising the top bar over the drawer instead would have been the smaller
change and is not available: the drawer is at 950 and a full-screen
composer at 800, so a top bar above the first is also above the second.
2026-08-31 22:04:25 -07:00
Coffey Labs 20929ddc2c Merge pull request #165 from Coffey-Labs/mobile-touch-targets-drilldown
Folders one level at a time on a phone, and targets a thumb can hit
2026-08-31 21:51:15 -07:00
jcoffey-dev 6c958b8609 Folders one level at a time on a phone, and targets a thumb can hit
Three things the mobile interface got wrong, all of them measurable.

The folder tree spent its width on depth. Four levels down, the 16px
indent steps and the 18px twisty left a folder 85px of a 300px drawer to
print its name in, and the twisty had walked far enough right that
hitting it was luck -- a miss landed on the row, which is a link, so the
wrong tap also cost a navigation. Under 768px the tree is now a
drill-down: one level at a time, no indent, a back row above it, and a
chevron at the right edge that is the same size in the same place on
every row. Tapping the row still opens the folder; only the chevron
changes what the list shows. The tree is untouched above 768px, where a
wide sidebar can afford the indent and where dragging a folder onto
another folder -- still the only way to reparent one -- needs both of
them on screen at once.

Every control in the top bar was under the 44px a fingertip covers: the
icons at 36, the search filter at 30, the row menu at 24, and the
hamburger 6px from the bezel in the corner a thumb is worst at. They
keep the size they draw at and gain a transparent hit area, since
growing the boxes would reflow a bar with no room to give; rows grow for
real, because a 44px target inside a 36px row reaches into its
neighbours. The one exception is the row menu, held to 36px wide: at a
full 44 it overlapped the drill chevron by 4px, so its right edge
silently drilled instead.

Pinch was dead on the message list. `.msg-row` sets `touch-action:
pan-y` to feed the swipe gesture the horizontal movement the browser is
not using -- but naming any value drops every gesture not named, zoom
included. It worked on an open message and died on the list, which reads
as the zoom being broken at random rather than as a rule about rows.
`pan-y pinch-zoom` keeps the swipe and gives the zoom back.
2026-08-31 21:47:30 -07:00
Coffey Labs 100f07b580 Merge pull request #164 from Coffey-Labs/fix-language-count
Ten languages, not nine: correct the count everywhere
2026-08-31 15:38:34 -07:00
jcoffey-dev ded2f4dc1b Ten languages, not nine: correct the count everywhere
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.
2026-08-31 15:36:04 -07:00
Coffey Labs 874887eb36 Merge pull request #163 from Coffey-Labs/docs-i18n-2026-08-31
Document the nine languages, and what Beta means on them
2026-08-31 15:20:07 -07:00
jcoffey-dev c26ca90e01 Document the nine languages, and what Beta means on them
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.
2026-08-31 15:18:01 -07:00
Coffey Labs 2380ed282e Merge pull request #162 from Coffey-Labs/i18n-first-paint-language
Keep a settings change made before the first read, and wait for it
2026-08-31 15:11:45 -07:00
jcoffey-dev 635c4c7e52 Keep a settings change made before the first read, and wait for it
Two defects on the path that decides what language the app starts in.

**A change made before the settings file came back was thrown away.**
`queueSettingsPush` returned early while unarmed, dropping the value
instead of holding it, so a language picked in the second or so after a
page load was never written up: it survived until the next reload and no
further. That is a better account of "sometimes it takes several clicks"
than the remount race fixed in #160 — the click that stuck was one made
after the read had finished. Keeping it is safe because hydrate already
refuses to overwrite a key that is still queued.

Proof it was real: before this, no `ihasmail` folder was ever created in
the account's files, because the seed write never fired. After it, the
folder appears.

**Without a cached settings object the tree painted too early.** The
cache is not read on an untrusted device, and it is cleared by the
sign-out that every deploy causes, so in both cases the first frame is
the defaults — and the defaults are English. Anything computed in that
window is computed in the wrong language. The interface recovers, since
it is rebuilt when the catalogue lands, but a string emitted once does
not: this is why the stale-folder toast came out in English on an
otherwise German screen.

So without a cache the authenticated tree now waits for the account's
settings and their catalogue, which costs nothing — there was nothing
worth painting yet. With a cache it does not wait, and the first frame is
as quick as it was.

Neither fix makes the toast German yet: the account settings file is
neither written nor read successfully in the mock, and both failures are
swallowed. That is a third problem, and this commit does not touch it.
2026-08-31 15:03:40 -07:00
Coffey Labs 67f5a88ff6 Merge pull request #161 from Coffey-Labs/i18n-second-extraction
Second extraction pass: the strings the codemod could not see
2026-08-31 14:46:36 -07:00
jcoffey-dev ba4d2105a4 Merge main: the list subject stays notranslate and gains its translated fallback
Both sides of the conflict belong. The span holds a subject, which is the
sender's words and not ours to machine-translate; the text shown when
there is no subject is ours, and should follow the interface language.
2026-08-31 14:44:30 -07:00
Coffey Labs 9b7f66a2c0 Merge pull request #160 from Coffey-Labs/i18n-switch-and-translate-prompt
Stop a language change undoing itself, and stop the translate prompt
2026-08-31 14:43:13 -07:00
jcoffey-dev a6863e98cc Second extraction pass: the strings the codemod could not see
`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.

176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.

Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.

Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.

Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.

And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
2026-08-31 14:14:30 -07:00
jcoffey-dev 51dabd9cab Stop a language change undoing itself, and stop the translate prompt
Two reports, both about the language setting.

**Picking a language sometimes took several clicks.** The subtree that
reads the account's settings file is keyed on the language version, so
choosing a language deliberately throws it away and builds it again. The
remount re-read the settings file — which still held the old language,
because the write is debounced by three seconds — and applied it, putting
the old language back. The click that appeared to work was the one made
after the previous write had landed, which is exactly the "sometimes"
in the report.

Worse than it looked: the queued push survived the remount, so the file
was eventually written with the new language while the screen showed the
old one. A reload then changed the language on its own.

Fixed twice over, because either alone leaves a race. The file is read
once per account per page load rather than once per mount, and hydrate
now holds back any key with a change still queued — a change that has
not been written up is newer than the file by definition. That rule is
`mergeRemote`, pulled out as a pure function so it could be tested
without a JMAP client.

**Both browsers kept offering to translate an English page.** They were
right to: `<html lang>` said English while the visible text was 6,289
message rows of marketing copy and brand names in whatever language the
sender wrote in. The list is most of the text on the screen, so that is
what the detector was reading.

Sender, subject and preview in the list, and the thread subject and
sender name in the reader, are now marked as what they are — content,
not interface. Message bodies were already marked, so this is the same
line drawn in the places the earlier pass missed rather than a new one.
Whether it silences the prompt is Chrome's call and cannot be checked
from inside the page; the marking is right either way.
2026-08-31 13:45:18 -07:00
Coffey Labs a4f7d386a6 Merge pull request #159 from Coffey-Labs/i18n-untranslated-labels
Translate the labels the extractor could not see
2026-08-31 13:24:45 -07:00
jcoffey-dev f94cc2ce51 Translate the labels the extractor could not see
Three places built user-visible English out of expressions rather than
writing it as JSX text, so the extraction codemod never found them and
they stayed English in all nine languages — including the five that have
been in production for weeks.

The calendar view switcher was the worst of them: it spelled its labels
as `v[0].toUpperCase() + v.slice(1)`, which is correct English and
untranslatable anywhere else. Day, Week, Month and Agenda were already in
every catalogue, sitting unused, because the buttons never asked for
them. They now come from a Record<View, () => string>, so TypeScript
makes the map exhaustive and adding a view forces adding its label. The
labels are functions rather than values: a module-level object would
capture whichever language happened to load first and keep it.

The other two needed new source strings, added to all nine catalogues:
the composer's title for an untitled draft ("New message"), its status
line ("Sending…", "Saving…", "Error", "Saved {when}", "Unsaved"), and the
agenda view's own title ("Agenda from {date}").

This is not the whole of it. A sweep for the same shape — template
literals, toast arguments, and dialog props rather than JSX text — turns
up roughly a hundred more strings, mostly toasts and confirmation
dialogs. Those are a second extraction pass rather than a fix, and are
left for one.
2026-08-31 13:20:39 -07:00
Coffey Labs a440e54922 Merge pull request #158 from Coffey-Labs/i18n-japanese
Add a Japanese interface catalogue
2026-08-31 13:17:27 -07:00
jcoffey-dev 3ab2b02ad9 Merge main: keep all four Phase 2 languages in the list 2026-08-31 13:13:47 -07:00
Coffey Labs 94e387267b Merge pull request #157 from Coffey-Labs/i18n-chinese
Add a Simplified Chinese interface catalogue
2026-08-31 13:13:16 -07:00
jcoffey-dev 2626b7a333 Merge main: keep ru, uk and zh-Hans in the language list 2026-08-31 13:11:09 -07:00
Coffey Labs 5ee9b1f1b1 Merge pull request #156 from Coffey-Labs/i18n-ukrainian
Ukrainian, and not the Russian one with a different name on it
2026-08-31 13:10:56 -07:00
jcoffey-dev a2a339ce32 Merge main: keep both ru and uk in the language list 2026-08-31 13:08:46 -07:00
Coffey Labs 1eb31450a4 Merge pull request #155 from Coffey-Labs/i18n-russian
Russian, and the first real use of the plural machinery
2026-08-31 13:07:44 -07:00
jcoffey-dev 12f08acd32 Add a Japanese interface catalogue
781 strings, machine-made and marked Beta, on the same terms as the
languages before it: the report link stands in for the native speaker we
do not have, and a missing entry falls back to English.

Plurals: there are none. Intl.PluralRules("ja") returns `other` for every
number, so each counted string carries one form. Counters do the work a
plural would — 通 for messages, 件 for conversations and items — which is
why "{n} messages" and "{n} conversations" are separate entries rather
than one pattern. The number alone does not decide the word after it.

Register is です・ます throughout, with the pronoun dropped: where English
says "your mailbox" this file usually just says メールボックス. Buttons
are the bare noun or verb stem — 送信, 返信, 削除 — not a sentence, which
is what every other mail client the reader has used does.

Script mixing is deliberate. Kanji for the noun carrying the meaning,
katakana for the loanword the reader already knows (メール, フォルダー,
アーカイブ), hiragana between them. Long vowels keep their ー.

Verified against the mock server: role folders localise and custom ones
are untouched, dates follow the language, and "{used} of {total}" comes
out reordered as "2.0 GB 中 700 MB" rather than word-for-word.
2026-08-31 13:05:33 -07:00
jcoffey-dev 988e741b79 Add a Simplified Chinese interface catalogue
781 strings, machine-made and marked Beta, on the same terms as the five
Phase 1 languages: the report link stands in for the native speaker we
do not have, and a missing entry falls back to English, so deleting a bad
line is a valid fix.

Two things differ in kind from the European catalogues, and the file
header records both so a later editor does not undo them.

Plurals: there are none. Intl.PluralRules("zh-Hans") returns `other` for
every number, so each counted string carries one form. Supplying `one`,
`few` or `many` would be filling in a distinction the language does not
draw, and none of them would ever be selected.

Script: this is Simplified, and the tag says so. A Traditional catalogue
would be a separate file rather than a character conversion of this one —
the vocabulary differs as much as the script does (软件/軟體, 文件/檔案),
and converting characters alone produces text that is readable and
obviously foreign.

Verified against the mock server: role folders localise and custom ones
are left alone, dates and the calendar follow the language, and the
catalogue code-splits into its own 41 kB chunk.
2026-08-31 12:58:29 -07:00
jcoffey-dev 5737362621 Ukrainian, and not the Russian one with a different name on it
781 of 796 strings. Generated by AI, unreviewed, marked Beta.

The thing this catalogue had to avoid is the reason it took the work it did.
Ukrainian and Russian share a script and share a plural rule -- one, few,
many, with 11 counting as many and 21 counting as one -- and share almost
nothing else that matters in a mail client. «Вхідні» is not «Входящие»,
«Кошик» is not «Корзина», «Листування» is not «Цепочка». A Ukrainian
catalogue produced by adapting the Russian one would pass every structural
check in this repo and still be the wrong language, and a Ukrainian reader
would notice in the first sentence and would be right to resent it. The
vocabulary here was chosen against what Ukrainian software says, not against
the neighbouring file.

Two words worth naming: «тека» rather than «папка» for folder, which is the
form Ukrainian software settled on; and «мітка» for label rather than
Russian's «ярлык», which in Ukrainian means a shortcut and would be a small
false friend on every screen.

Plurals tested against the shipped catalogue at 1, 2, 4, 5, 11, 21 and 0, plus
the check that every counted string carries all four categories. A missing
"few" falls back to "other" silently and is grammatical often enough to go
unnoticed.

The cross-check that Ukrainian and Russian are actually different files is
worth having but cannot live here: Russian is still an open pull request, so
ru.ts does not exist on this branch. It belongs in a follow-up once both have
landed.
2026-08-31 12:44:53 -07:00
jcoffey-dev f9d521a412 Russian, and the first real use of the plural machinery
781 of 796 strings. Generated by AI, unreviewed, marked Beta.

This is the catalogue plural() was designed for. Russian needs three forms
where English has two, and the choice is not a question about the number 1:
1 is "one", 2-4 are "few", 5-20 are "many", 11-14 are "many" despite ending in
1-4, and 21 is "one" again. Intl.PluralRules knows all of that; a two-form
assumption would have shipped "5 письмо" and read as machine output however
good the vocabulary was.

Tested against the shipped catalogue rather than a fixture -- 1, 2, 3, 5, 11,
21, 22, 25 and 0 -- plus a check that every counted string carries all four
categories, because a missing "few" falls back to "other" silently and is
grammatical often enough to go unnoticed.

"Выбрано: {n}" for the selection count rather than an agreeing form: the
impersonal construction sidesteps agreement entirely and is what Russian
interfaces actually do there.

Register is "вы", lowercase. Capitalised «Вы» is correspondence style and
reads as a letter rather than as software, so it would be a small constant
wrongness on every screen. Most of the interface avoids the question anyway,
because Russian UI convention is the infinitive for actions.

«Письмо» rather than «сообщение» for a mail message, which is what Russian
mail clients call one; «сообщение» reads as a chat message. «Ярлык» for label,
Gmail's word in Russian -- a fifth answer to the same rule about using what the
reader will meet elsewhere.

A stray CJK character got typed into one Russian sentence during drafting and
was caught by sweeping the file for anything outside the expected scripts. Not
a mistake a spellcheck would find, and not one a reader would forgive.
2026-08-31 12:38:51 -07:00
Coffey Labs 8270843c03 Merge pull request #154 from Coffey-Labs/i18n-portuguese-br
Portuguese (Brazil), completing Phase 1
2026-08-31 12:21:07 -07:00
jcoffey-dev ce28e014d3 Merge main: keep both language entries 2026-08-31 12:21:02 -07:00
Coffey Labs 461f803f4c Merge pull request #153 from Coffey-Labs/i18n-spanish
Spanish, fourth of Phase 1
2026-08-31 12:20:30 -07:00
jcoffey-dev 70af64b126 Merge main: keep both language entries 2026-08-31 12:18:34 -07:00
Coffey Labs d75618401a Merge pull request #152 from Coffey-Labs/i18n-dutch
Dutch, third of Phase 1
2026-08-31 12:18:01 -07:00
jcoffey-dev fcbe268715 Merge main: keep both language entries 2026-08-31 12:16:11 -07:00
Coffey Labs 49e703b8bb Merge pull request #151 from Coffey-Labs/i18n-french
French, on the same terms as German
2026-08-31 12:14:42 -07:00
jcoffey-dev 4ecfbd25a5 Register Dutch, and catch a catalogue nobody can select
nl.ts shipped without an entry in UI_LANGUAGES, so the language was never
offered: the catalogue built, every test passed, the coverage check reported
98%, and the picker did not list it. The entry was added by a text replacement
anchored on the French line, which does not exist on a branch cut from main, so
the replacement was a silent no-op.

A catalogue and a picker entry are two halves of one thing and either half
alone is dead weight, so the checker now verifies both directions -- a
catalogue with no entry, and an entry with no catalogue. Reverting the
one-line fix makes it fail, which is the only way to know a check works.
2026-08-31 12:12:39 -07:00
jcoffey-dev fc5c8dd4fa Portuguese (Brazil), completing Phase 1
781 of 796 strings; the fifteen left are product names, bare URLs and example
addresses. Generated by AI, unreviewed, marked Beta.

This is Brazilian Portuguese specifically, and the tag says so rather than
claiming "Portuguese". It is not a stand-in for European Portuguese: the
vocabulary diverges in exactly the places a mail client lives -- arquivo
against ficheiro, tela against ecrã -- and offering one variety as though it
were the other is worse than offering English, because the reader cannot tell
it was not meant for them. A pt-PT catalogue would be a separate file.

Register is "você", and this is the one place Phase 1 deliberately breaks its
own rule. The other four all took the formal address; Brazilian Portuguese has
no comfortable equivalent. "O senhor" is deferential rather than merely polite
and reads as stiff or sarcastic in software, while "você" is the neutral
default Gmail, Outlook and every Brazilian bank use, carrying none of the
familiarity "du" or "tu" would elsewhere. Following the rule here would have
produced a worse translation by obeying a decision made about other languages.
The rule was always "address the reader the way the language does it", and
these five are what that looks like rather than five copies of one answer.

"Marcador" for label, which is Gmail's word in Brazil: a fourth different
outcome from the same rule about using what the reader will meet elsewhere,
after English, Libellé and Etiqueta.

Phase 1 is complete: de, fr, nl, es, pt-BR, each unreviewed and each marked
Beta until a speaker signs it off. That review is the part nobody has done and
the part that decides whether any of this was worth shipping.
2026-08-31 12:00:00 -07:00
jcoffey-dev 8d19108498 Spanish, fourth of Phase 1
781 of 796 strings; the fifteen left are product names, bare URLs and example
addresses. Generated by AI, unreviewed, marked Beta.

Register is "usted", following the other three. Spanish makes the decision
cheaper than they did: most of an interface is infinitives and nouns --
"Eliminar", "Configuración" -- where the question never arises. It only shows
in the sentences that address the reader directly, and those agree.

This is peninsular Spanish where the varieties diverge, chosen deliberately
rather than blended, because a blend reads worse than either. The file names
the differences that actually matter in a mail client -- "correo" over
"email", "ordenador" over "computadora" -- and notes that a Latin American
catalogue would be a copy of this one with those changed, not a fresh
translation. Worth writing down while the reasoning is fresh rather than
rediscovering it if es-419 is ever asked for.

"Etiqueta" for label, as in French: Gmail established it and a reader will
find it there. That is the third application of the same rule -- use what the
reader will meet elsewhere -- and the third different outcome, which is what
a rule looks like when it is doing work.
2026-08-31 11:55:12 -07:00
jcoffey-dev e9009f7aaf Dutch, third of Phase 1
781 of 796 strings; the fifteen left are product names, bare URLs and example
addresses. Generated by AI, unreviewed, marked Beta, with the report link doing
the job a native speaker would.

Register is "u", following "Sie" and "vous". This is the one of the three most
likely to be overturned, and the file says so: Dutch leans informal further and
faster than German or French, and "je" is what most consumer software now uses.
It is written down as a single consistent choice precisely so that changing it
is a find-and-replace rather than an argument. Where a string can dodge the
question it does, which is ordinary good Dutch UI.

"Postvak IN" rather than "Inbox", because that is what Outlook and Thunderbird
call it in Dutch. "Label" stays English as in German -- no Dutch client
translates it -- which is the same rule that made French use "Libellé": use
what the reader will meet elsewhere, rather than always translating or never.

The folder-context separator needed inserting by script again, and in the
opposite way to French: there it arrived as a raw control byte and had to be
escaped, here it did not arrive at all. Both are the same underlying awkwardness
-- U+0004 does not survive being written into a file by hand -- and the
catalogue checker caught it both times by reporting the keys as stale, which is
exactly the silent failure it exists for.
2026-08-31 11:50:45 -07:00
jcoffey-dev 53c38ed3c3 French, on the same terms as German
Second of Phase 1. Generated by AI, unreviewed, marked Beta, with the report
link in Settings doing the job a native speaker would otherwise do. 781 of 796
strings; the fifteen left are product names, bare URLs and example addresses,
which should stay English.

Register is "vous", following the "Sie" decision and for the same reason: a
mail client a workplace deployed has no business addressing anybody as "tu".

One terminology decision goes the opposite way to German, deliberately.
"Label" stays English in German because no German client translates it, and
becomes "Libellé" in French because Gmail did and a French reader will meet it
there. The rule being followed is "use what the reader will find elsewhere",
not "always translate" or "never" -- which only looks inconsistent if the rule
is mistaken for the outcome.

French typography: the narrow no-break space before ? ! and : is the rule and
is deliberately not used. It is invisible in a diff, trivially lost in an
editor, and no French webmail actually ships it. Guillemets are used, because
those are visible and do read as wrong when missing.

Two things worth recording from doing this a second time.

The folder-context separator arrived as a raw U+0004 byte rather than the
\\u0004 escape the German file uses. It would have worked -- TypeScript accepts
it -- and it is invisible in an editor and in a diff, which is exactly why the
German catalogue writes it as an escape. Converted, so both files say the same
thing in the same way.

And the language tests named a real language as their example of one that is
not shipped, so shipping German broke them, and shipping French broke them
again. Both times the failure was the test being out of date rather than
anything wrong. They derive an unshipped tag now, and assert over every shipped
language rather than a hardcoded pair, so the third and fourth languages will
not repeat it.
2026-08-31 11:44:40 -07:00
Coffey Labs 8cbc04e730 Merge pull request #150 from Coffey-Labs/i18n-folder-names
Folder names follow the language, and three bugs that found
2026-08-31 11:27:29 -07:00
jcoffey-dev 71dd2e108f Folder names follow the language, and three bugs that found
Answering "can we ask Stalwart to serve German folder names": no, and it would
not help if we could. The account locale exists in `x:AccountSettings`, and
ihasmail already reads it -- that is what "Your mail server reports German"
comes from -- but writing it needs `sysAccountSettingsSet`, which the built-in
user role does not carry; only an admin could. And even then it would change
nothing, because folder names are stored data written once when the account is
provisioned. No server renames them afterwards; every other client has them
mapped.

The role is the way through. JMAP tags the standard folders and ihasmail
already trusts the role over the name everywhere it matters, so the *displayed*
name can follow the interface language with nothing written to the server. A
folder somebody made and called "Newsletters" keeps that name: those are their
words, and translating them would name a folder they never created.

The cost is real and worth stating: Thunderbird on the same account still shows
"Deleted Items", because that is what the folder is called. Inside ihasmail it
stays consistent -- everything that names a folder goes through one function,
including the "moved to …" toast, which exists precisely so that message does
not name somewhere the reader cannot find. Renaming still edits the server's
own name, never the localised one.

Three things fell out of it.

The message list refreshed for ever after a language change, which is the one
somebody noticed. The root keys its tree on the language version, so a publish
remounts everything; remounting re-runs the effect that loads the account's
settings, which calls applyLang, which called setCatalog again -- with an
identical tag and an identical catalogue -- and publishing that non-change went
round again. setCatalog now returns early when nothing changed. Measured
rather than assumed: three consecutive five-second windows with no JMAP calls
at all, against a pre-change count that never settled.

Calendar months and weekdays stayed English, because formatting locale and
interface language are separate settings and only the first feeds Intl.
Keeping them separate is right -- German dates with an English interface is a
real preference -- but somebody who picks German and is shown "September" has
not got what they asked for. A chosen interface language now joins the
*automatic* chain ahead of the server and the browser. Setting a formatting
locale explicitly still wins, and English is not counted, so an English
interface on a German browser keeps German dates exactly as before.

And the Archive folder read "Archivieren", which is the verb. English uses one
word for the button and the folder; German does not, and neither does
"Important", which is also a priority tag. tc(context, source) keys the
catalogue on both and falls back to the plain English, which was right in
English all along -- the gettext approach, including the control character as
separator so no real string can collide.

The catalogue checker needed teaching about tc() twice: first it reported the
eight contextual entries as stale, then it asked for the plain fallbacks as
though they were a second obligation. A check that reports work which does not
exist gets switched off, which is worse than not having one.
2026-08-31 11:24:24 -07:00
jcoffey-dev 87383440bb German, generated by AI and marked Beta until somebody signs it off
The first language, and the first one where the honest thing to say is not
flattering: no native speaker has read it. That is stated in the app rather
than in a commit nobody reads, because it is the fact a reader needs to judge
what they are looking at. Somebody told a translation is unchecked forgives an
odd sentence and reports it; somebody told it was reviewed reasonably concludes
the product is sloppy. The setting carries a link straight to a report, which
is the whole review process here.

`beta` is a property of the language, not of the catalogue's completeness. A
file can be word-for-word finished and still read like a machine wrote it, and
that is what the flag marks. Removing it is a person's decision.

Register is "Sie", consistently, and written down in the file so the next
language and the next contributor inherit the decision rather than re-take it.
Thunderbird and Outlook use it; ihasmail is as often a company's mail as
somebody's own, where "du" from software the workplace deployed reads as
presumptuous. Where a string can dodge the question it does, which is ordinary
good German UI. The glossary at the top of the file fixes the vocabulary once
-- Posteingang, Papierkorb, Entwürfe, archivieren -- because inconsistency
reads as amateur far more than an imperfect word choice does. "Label" and
"Spam" stay English, since translating them would name things no German mail
client calls that.

766 of 781 strings. The fifteen left are product names, bare URLs and example
addresses, which should stay English and now do.

Two things this turned up that the earlier work had hidden:

Labels defined as module-level constants -- the entire settings navigation,
the theme cards, the swipe choices, the date formats, the sharing permissions
-- are evaluated once, before any catalogue loads, so they could only ever be
English. Nothing failed; the German build simply had an English sidebar. They
are translated where they render now, which keeps the constant as data and
makes its English text the key.

And the codemod's narrowed rule, which let it take 73 more strings last time,
was too broad after all: text stranded after an inline <a> or <strong> came
through as sentence fragments -- ", and what a new account starts on." Eight
of them, rebuilt with tNode so the sentence stays whole and the element is a
named hole a translator can move.

scripts/i18n-catalog-check.mjs is new and earned itself immediately: it found
three keys invented that the code never asks for, which is the silent failure
in a catalogue -- a translation that looks right, is never looked up, and
renders English for ever. It also had to be taught about t(variable), because
it cried wolf 33 times over the constants above, and a check that cries wolf
gets switched off.

Verified in the browser rather than only in tests, which is where the settings
sidebar being English was visible and nowhere else.
2026-08-31 11:06:54 -07:00
Coffey Labs d0cbfc7870 Merge pull request #148 from Coffey-Labs/i18n-extract-batch2
Finish extraction: 100%, and a coverage number worth believing
2026-08-31 10:45:19 -07:00
jcoffey-dev 3f4b33cb51 Finish extraction: 100%, and a coverage number worth believing
The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.

A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.

The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.

Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.

The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.

596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
2026-08-31 10:41:24 -07:00
Coffey Labs 46c1dc28e3 Merge pull request #147 from Coffey-Labs/i18n-extract-batch1
Extract 515 strings by codemod
2026-08-31 10:02:15 -07:00
jcoffey-dev 8ea611f7f7 Extract 515 strings by codemod, and the two bugs only a screenshot caught
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy
itself, and a parser does not get bored. scripts/i18n-extract.mjs does the
mechanical part -- JSX text and the attributes a person actually reads -- and
refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left.

What it refuses matters as much as what it does. Text split around an
interpolation arrives as separate fragments, and wrapping each on its own
produces "Move " and " messages", which no translator can do anything with;
those are listed for a person to rebuild as sentences. So is anything
containing a double quote, which would end the literal.

Three things it had to be taught, each found by running it:

- <code>, <kbd> and <pre> are not prose. The first run wrapped `label:name`
  inside <code> -- a search operator, where translating it breaks the thing it
  documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
  it, so an import called `t` is shadowed inside those callbacks -- silently,
  wherever the local happens to be callable. The name is checked per file now
  and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
  `Language &amp; region` moved into t("...") and rendered the entity on screen.

That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language &amp; region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.

The codemod decodes entities now, and checks for a quote after decoding rather
than before.
2026-08-31 09:58:33 -07:00
jcoffey-dev 95dcb96086 Start extraction: an i18n core, and a way to see how far it has got
The groundwork in #145 gave the app a language to serve. This gives it
something to serve, and a way to measure the distance to the languages
actually planned.

The English text is the key. `t("Archive")` looks "Archive" up and returns the
English when it is not there, which buys three things worth more than tidy
symbolic keys: no English catalogue to keep in step with the code, a missing
translation that degrades to readable English rather than to
`mail.list.archive`, and an extraction step that is wrapping a string rather
than inventing a name for it. Names are where extraction stalls, and 55
components is a lot of small naming arguments. The cost is that editing English
copy orphans its translations, which is the right way round: the copy is the
product, and a stale German sentence should fall back to the new English.

`plural()` takes forms rather than (one, other), because two forms is an
English assumption that does not survive phase two of the plan. Russian and
Ukrainian need three, and choosing between them is not a question about the
number 1. Intl.PluralRules knows the rule for every language the browser knows,
so the catalogue supplies the forms and the runtime picks; a category the
catalogue does not carry falls back to `other` rather than rendering undefined.
Interpolation is named rather than positional for the same reason -- German
moves the parts of a sentence around and means the same thing.

Catalogues are dynamically imported, so a reader who never leaves English never
downloads one, and English needs no fetch at all. `applyLang` sets the lang
attribute before kicking the load, deliberately: lang is what stops Chrome
offering to translate and should not wait on a network request to say something
it already knows.

`t()` is a plain function, not a hook, so the tree is keyed on a language
version at the root and thrown away when the catalogue changes. Making every
call site a subscriber would turn extracting a string from "wrap it" into "wrap
it and add a hook", for an event that happens about once per account.

NotificationsSettings is extracted end to end as the reference -- it covers all
four shapes, being JSX text, translated attributes, a toast, and a sentence
with a value interpolated into it.

scripts/i18n-coverage.mjs counts what is left, because ~1,000 strings across 56
files is too many to eyeball in review or carry in anyone's head. It reports 20
wrapped and 925 remaining, and it deliberately does not count punctuation and
separators as untranslated -- a floor no amount of work could reach would make
the number useless. A progress report rather than a gate: --check exits
non-zero, for once the number is low enough for that to mean something.

ROADMAP.md said translations were "English-only for now" on a page whose stated
purpose is things the answer is "no" to. It now says what is actually happening,
carries the phase order, and says why Arabic, Hebrew and Persian are on neither
list: RTL is a layout and bidi problem rather than a longer catalogue, and
shipping it as though it were the same kind of work is how an RTL build ends up
unusable with nobody saying so.
2026-08-31 09:44:49 -07:00
Coffey Labs 90ed579876 Merge pull request #145 from Coffey-Labs/translate-resilience
Defend against Chrome rewriting the DOM, and add the language setting
2026-08-31 09:29:30 -07:00
jcoffey-dev be1d787b5f Defend against Chrome rewriting the DOM, and add the language setting
Groundwork for un-shelving translations. Chrome's translator rewrites the
rendered DOM directly, wrapping text nodes in <font> elements React has never
heard of, and the next update can then call removeChild against a parent whose
children have moved (facebook/react#11538). This is the structural defence
against that, plus the setting the served language will read from.

The language setting is `uiLanguage`, and it is deliberately not the `locale`
field that already exists. That one is a formatting choice -- what calendar,
clock and numerals to use -- and folding the two together would silently
rewrite everybody's date format the first time they picked a language. German
dates with an English interface is a real preference, and so is the reverse.
It defaults to English when absent, which covers both a new account and every
settings file written before this, and Accept-Language is not consulted: a
served locale should be something the reader chose rather than something
guessed and then written down as though they had. Only languages with strings
shipped are offered, which today means English alone -- a picker entry without
a catalogue behind it would leave the page claiming a language it is not in,
which stops a reader translating a page they cannot read.

`<html lang>` is set where applyTheme is set: at store module load, from the
localStorage cache, before createRoot() has rendered anything. Not in an
effect -- a lang that is briefly wrong is enough to raise the translate prompt
on a page that needed none. There is no server-rendered alternative to reach
for here: ihasmail serves a static shell and holds no account state, and the
settings file lives in the reader's own JMAP Files, so reading it before the
page existed would mean authenticating to Stalwart on every page load. The
static lang="en" in index.html covers the first bytes; the store only ever
corrects a reader who chose otherwise. Both halves are tested.

translate="no" and class="notranslate" go on the narrow boundaries only:
rendered email bodies, raw message source, attachment text, the generated and
hand-edited Sieve, the brand and the login name. Not on <body> -- someone
whose language ihasmail does not speak yet should still be able to translate
the parts that are ours. Email bodies turn out to live in a shadow root, so
React never reconciles them and they were never a crash risk; the marker there
is about not rewriting what a sender actually wrote.

Twenty-four fragile interpolation points were found with the TypeScript
parser rather than grep, and fifteen refactored. Pluralisation and
"count + label" pairs are collapsed into a single expression so the text is a
lone child React updates with textContent, rather than a text node with
conditional siblings to insert around. One of them -- InviteCard's
{method === "REPLY" && organizer ? "" : ""} -- rendered an empty string either
way and is simply gone.

The boundary is scoped to the main content, so the header, folder tree and any
open composer sit outside it and survive independently. It recovers by
remounting the subtree, which costs nothing because everything inside
re-derives from the stores, and it logs at info rather than error: a reader
translating a page is expected and recovered from, and filing it as an error
would put an entry in every console-reading reporter for behaviour that
worked. It re-raises anything that is not a DOM mutation error, so a real bug
still surfaces as one, and it gives up after three attempts rather than
looping invisibly.

Worth recording: the crash could not be reproduced on React 19.2.8. Wrapping
207-249 React-managed text nodes in <font>, exactly as the translator does,
then driving in-place conditional toggles and navigations, left the app intact
with the boundary never firing. The original issue is from React 16 and the
reconciler has changed a great deal since. So this lands as defence whose
premise is weaker than assumed rather than as a fix for something observed
here, and the boundary is insurance rather than a load-bearing part. The
notranslate markers and the collapsed interpolations stand on their own merits
either way.
2026-08-31 09:14:41 -07:00
Coffey Labs 35c6060ceb Merge pull request #144 from Coffey-Labs/push-survive-deploy
Keep push alive across a deploy, not just across a week
2026-08-31 08:24:25 -07:00
jcoffey-dev d255c20215 Keep push alive across a deploy, not just across a week
#143 added a device-local flag recording that background notifications were
switched on in this browser, and made the renewal on app start key off it. It
is not in KEEP_ON_SIGN_OUT, and that is the whole bug: clearSignedInData()
runs on two different endings and only one of them is a sign-out.

The other is a session expiring, which is what a deploy does to every signed-in
browser at once. That path deliberately does not remove the push subscription
-- there is no session left to remove it with -- so the subscription stays
registered at Stalwart and the browser keeps its own. Losing the flag there
left nothing to renew them: push would have gone quiet a week after every
deploy, with the switch in Settings still reading as on because both ends of
the subscription still existed. That is the exact failure #143 was written to
prevent, reintroduced through a different door, and the first deploy carrying
#143 would have been the thing that triggered it.

Signing out for real still forgets it. That happens directly in
unsubscribeThisDevice, next to destroying the subscription, and it happens even
when the server cannot be reached -- a browser that goes on believing it has
push would have renewal resurrect it on the next sign-in.

Both halves are tested now, because they are one invariant seen from two sides:
storage.test.ts covers the flag surviving an expiry, webpush.test.ts covers a
sign-out clearing it with the server unreachable.
2026-08-31 08:20:31 -07:00
Coffey Labs 2bf3137484 Merge pull request #143 from Coffey-Labs/push-renewal
Renew the push subscription, so it does not lapse in a week
2026-08-31 08:14:32 -07:00
jcoffey-dev 562cee82ce Renew the push subscription, so it does not lapse in a week
Background notifications were built, verified against a live server, and then
went quiet a few days later on every device that had them. A JMAP push
subscription expires -- seven days is the ceiling -- and re-registering before
it lapses is the client's job. Nothing did: enableWebPush() was reachable only
from the switch in Settings, so the subscription was registered once, expired,
and stayed expired. Nobody reports that as a bug. They report that push does
not really work.

It is renewed on every app start now, which is the only place it can be: the
registration is a JMAP call and the service worker has no session cookie to
make one with. So the guarantee is that push keeps working as long as ihasmail
is opened now and again, and a two-day renewal window against a seven-day
ceiling means once a week is enough. Registering is the same call as turning it
on -- deviceClientId makes a repeat replace rather than accumulate -- so there
is no second path to get wrong.

Two more things in the same area, both of which produce the same silence:

- webPushActive() asked whether the *account* had any subscription, so the
  moment one device had one, every other device showed the switch already on.
  A phone that had never successfully registered, or whose registration had
  since expired, read as on and delivered nothing. It matches on the device now.
- Turning push on reused an existing browser subscription and gave up if there
  was none. A browser drops or rotates one on its own, and there is no tab open
  to hear the pushsubscriptionchange when it does, so that state was permanent.
  Renewal re-subscribes rather than bailing.

Whether this browser has push on is now remembered locally, which is what
renewal keys off. It is per browser rather than per account on purpose: a
subscription is an endpoint and a device, and a phone having push says nothing
about the desktop. It is not kept across sign-out, matching sign-out already
destroying the subscription itself.

The mock is the reason this was invisible in development: it handed back
expires: null, so a client that never renewed worked perfectly against it
forever. It expires a subscription in seven days now, which is what makes
"does this client renew?" a question the mock can answer.

Checked against the mock: a create returns an expiry seven days out that
survives PushSubscription/get and parses, renewing the same deviceClientId
replaces rather than accumulates, and a device with no registration of its own
finds nothing where the old code saw two subscriptions and said yes. What the
live Stalwart sets for expires is not confirmed -- if it sets none, renewal
correctly does nothing and the other two fixes still stand.
2026-08-31 08:03:43 -07:00
Coffey Labs e17d109ece Merge pull request #142 from Coffey-Labs/features-touch-gestures
Write down what the mail list does under a finger
2026-08-31 07:51:28 -07:00
jcoffey-dev 42dfdc5a44 Write down what the mail list does under a finger
FEATURES.md and the touch gestures were written at the same time on separate
branches, so the inventory of everything ihasmail does landed knowing about
none of them: the mobile layout was still one line about a tab bar, and the
Appearance row of the settings table did not mention the two settings that
had just been added to it.

The gestures get a section of their own under Layout rather than a bullet,
because the interesting part is not the list of five -- it is why they are
gated on `(pointer: coarse)` rather than on width, why a direction with no
meaning in this folder refuses to move rather than moving and doing nothing,
and why the axis lock gives a diagonal drag to the scroller. The same
reasoning is in the code; this is where somebody reads it without opening
lib/touch.ts.

Also here: the `dvh` and safe-area notes alongside the mobile layout, a long
press added to the list's multi-select bullet, and a short note in the
settings-sync section on why the swipe actions follow the account despite
looking like a per-device setting.
2026-08-31 07:03:29 -07:00
Coffey Labs c987ecccf3 Merge pull request #141 from Coffey-Labs/mobile-gestures
Give the mail list the gestures a phone already has
2026-08-31 06:57:26 -07:00
jcoffey-dev b2769b9011 Give the mail list the gestures a phone already has
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.
2026-08-31 06:52:25 -07:00
Coffey Labs c1c448b94d Merge pull request #140 from Coffey-Labs/features-inventory
Write down everything ihasmail does
2026-08-30 23:05:10 -07:00
jcoffey-dev f736bf0c34 Write down everything ihasmail does
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.
2026-08-30 23:02:14 -07:00
Coffey Labs 0fb504748d Merge pull request #139 from Coffey-Labs/immutable-by-default
Deploy immutably by default
2026-08-30 22:33:15 -07:00
jcoffey-dev 34e37e1786 Deploy immutably by default
Forgetting IHASMAIL_IMMUTABLE handed back a writable container with a
volume mounted, quietly, and then reported healthy. Nothing in the output
said the immutability had gone -- `docker inspect` was the only place it
showed, and only if you thought to look.

That is the wrong way round for a posture the project leads with. The
safe one is now what you get by default and giving it up is the half that
has to be deliberate.

Found by deploying prod: the running container had IMMUTABLE=1 and a
read-only root, and reproducing that took passing the variable by hand
because the script's default would have taken it away.
2026-08-30 22:26:21 -07:00
Coffey Labs 806e63d071 Merge pull request #138 from Coffey-Labs/mock-moved-occurrence
Mock: let an override move an occurrence, as the server does
2026-08-30 21:55:10 -07:00
jcoffey-dev 06943fd473 Mock: let an override move an occurrence, as the server does
Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
series moved to 14:00 comes back with `start` at 14:00 and
`recurrenceId` still at 09:00. The slot the rule made stays put; only the
clock time moves.

The mock set `start` from the slot after merging the override, so it
clobbered any `start` the override carried and a moved occurrence did not
move. Per-occurrence *time* editing - one of the main things the feature
is for - therefore looked broken against the mock and correct against the
server, which is the wrong way round for a mock to be wrong.

It also confirms the choice of handle: `recurrenceId` is the one name for
an instance that survives both a renumbering and a move, which is why the
store re-resolves from it rather than from `start` or a cached id.
2026-08-30 21:49:53 -07:00
Coffey Labs 362bd8b282 Merge pull request #137 from Coffey-Labs/calendar-per-occurrence
Calendar: edit and delete a single occurrence
2026-08-30 21:45:15 -07:00
jcoffey-dev 91481965bc Calendar: never mutate an occurrence by an id we are holding
Verified against the live 0.16.20 instance, which found two things the
mock had guessed wrong about.

A synthetic id encodes a position in the expanded series, and writing a
`recurrenceOverrides` entry renumbers it. A five-week series came back as
`e i m q u` over 03-01..03-29; after one override was written to 03-08
the same five ids addressed 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing
was rejected. A stale id is not invalid, it is wrong - a confident answer
about the wrong day - so a delete meant for one occurrence removes
another.

`recurrenceId` is the stable name for a slot in a series, because it is
the date. `updateEvent` and `destroyEvent` now look the current id up by
it immediately before acting, and refuse outright when the date has left
the series rather than falling back to the id in hand.

The mock had this exactly backwards: it kept ids stable on purpose, which
agreed with the belief that is wrong. It now renumbers too - a different
permutation to Stalwart's, with the property that matters - and a test
holds an id across a write and watches it change meaning.

Second finding: the inherited properties are dropped *after* the server
has decided to write an override, so a patch made only of them still
writes one, carrying the server-filled start and duration and nothing
else. `{"privacy":"private"}` on one occurrence answered "updated", left
privacy untouched, and left that date with no title at all. Sending
nothing when narrowing empties a patch was written as a principle - a
request whose response could only be a meaningless "updated" is worse
than no request - and it turns out to prevent real data loss.

Both recorded in KNOWN-ISSUES with the dates they were confirmed on.
2026-08-30 21:39:30 -07:00
jcoffey-dev 5ced44ec13 Calendar: drop the per-event colour picker, which categories replaced
A category carries a colour. Offering a separate colour picker beside it
made two ways to say the same thing, and they could disagree: an explicit
colour wins over the category's in `eventColor`, so an event could be
filed under Work and drawn in the Travel colour with nothing on the menu
explaining why.

Categories are the one that carries meaning, so the swatch grid goes and
picking a category is how an event gets a colour.

Clearing an explicit colour stays, and only appears when there is one to
clear. An event that already has one - set before this, or by another
client, or by CalDAV - would otherwise ignore its category for ever with
no way to fix it from here. Same reasoning as leaving "Stop sharing" on a
folder whose share nobody can see: the escape hatch is worth most exactly
when the thing it undoes is invisible.

Nothing reads differently for events without an explicit colour, and
`CALENDAR_COLORS` is untouched - categories, labels and mailboxes all
still pick from it.
2026-08-30 21:35:01 -07:00
jcoffey-dev dd8998f178 Calendar: edit and delete a single occurrence
Closes #132.

Stalwart 0.16.20 accepts a synthetic id on `CalendarEvent/set`, writing a
`recurrenceOverrides` entry rather than touching the series, so editing
one date of a recurring event is now something the server does and this
does too.

Editing asks the scope *before* the form opens, because it decides which
event the form is even about: a form populated from the master shows the
series' start date, so editing Wednesday's standup would have offered to
move Monday's. Deleting asks in place of the old confirm.

The patch is narrowed rather than posted hopefully. 0.16.20 sorts
per-occurrence properties into three groups and only one is honest: ten
are refused with `invalidProperties`, twelve more are dropped from the
patch while the response still reports success, and the rest are applied.
That silent middle group is how #26 reached a live server - a successful
response is not evidence anything was written - so `occurrencePatch`
throws on the first group, reports the second to the caller, and the
editor leaves out the five it always sends. A patch that would be
entirely dropped is not sent at all.

The refusal for an occurrence of a this-and-future change offers the
series instead of a bare error toast. Nothing here writes one of those,
but an event synced from another client can carry one.

Two things the scope prompt cost, both worth knowing. A dialog is queued
in a store the moment it is asked for, so it outlives the effect that
asked: without a ref guard a remount queues a second prompt the first
answer cannot retract. And gating the *answer* on the effect's cleanup
flag is worse - StrictMode runs mount, cleanup, mount, so the flag is
already set by the time anyone clicks and the editor never opens.

The mock expands recurrences for the first time, which is what makes any
of this developable. It hands out synthetic ids for everything including
one-offs, gives occurrences a `recurrenceId` and no rule, and reproduces
the refusals - including the silent drops, since a mock that applied them
would let a client that sends them look correct everywhere but a real
server.
2026-08-30 21:35:01 -07:00
Coffey Labs 6ec2304fc2 Merge pull request #135 from Coffey-Labs/calendar-event-scope
Resolve the base event id in the calendar store, not at the call sites
2026-08-30 21:29:59 -07:00
Coffey Labs d6f9ef1243 Merge pull request #136 from Coffey-Labs/version-tense
Stalwart has not reached 1.0 yet
2026-08-30 21:28:08 -07:00
jcoffey-dev b0cb73a924 Stalwart has not reached 1.0 yet
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.
2026-08-30 21:22:07 -07:00
jcoffey-dev 373822c2ae Resolve the base event id in the calendar store, not at the call sites
Closes #133.

`updateEvent`, `destroyEvent` and `rsvp` took an id and sent it. The
`baseEventId ?? id` that made them hit the series lived at four call
sites instead, and every one of them happened to be right.

That was backstopped by the server until now. Through 0.16.19 a synthetic
id reaching `destroy` came back as "Deleting synthetic ids is not yet
supported" and the user saw a toast. 0.16.20 accepts it and removes one
date instead, reporting success under a dialog that said "Delete all
occurrences?" - so a forgotten `??` became silent data loss rather than
an error.

The three methods now take the event and a required `scope`, and there is
exactly one place that turns an event into an id. A caller that wants the
series cannot get an occurrence by forgetting anything; a caller that
wants one occurrence has to say so.

`rsvp` takes the event rather than an id for the same reason, and no
longer looks it up: its patch is `participants/{key}/participationStatus`,
which is one of the pointers 0.16.20 *allows* on an occurrence, so aimed
at an instance it would quietly mean "only that day".

`findByUid` says in a comment that its query omits `expandRecurrences` on
purpose, since InviteCard hands the result straight to `destroyEvent`.
2026-08-30 21:06:56 -07:00
Coffey Labs 12157f47bf Merge pull request #134 from Coffey-Labs/stalwart-0.16.20
Stalwart 0.16.20 on the live instance
2026-08-30 21:02:22 -07:00
jcoffey-dev e41742a26c Stalwart 0.16.20 on the live instance
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.
2026-08-30 20:58:52 -07:00
Coffey Labs 1e01b34384 Merge pull request #131 from Coffey-Labs/github-org-coffey-labs
Point at the Coffey-Labs organisation
2026-08-30 15:26:58 -07:00
jcoffey-dev 95f640b24c Point at the Coffey-Labs organisation
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.
2026-08-30 15:17:43 -07:00
Coffey Labs 826a13ab39 Merge pull request #130 from LINUXexpert-org/calver-decouple-from-stalwart
Stop borrowing Stalwart's version number
2026-08-30 14:42:02 -07:00
jcoffey-dev 2f55b1e3e1 Stop borrowing Stalwart's version number
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.
2026-08-30 14:38:07 -07:00
Coffey Labs d75e9bc769 Merge pull request #129 from LINUXexpert-org/link-project-site-v2
Link the project site from inside the app
2026-08-30 14:19:21 -07:00
jcoffey-dev 08fd08e6fe Link the project site from inside the app
ihasmail.org was linked only from the login screen footer -- a page a signed-in
user sees once and then never again. From inside the app there was no way back
to the project site at all; Documentation went to docs.ihasmail.org and that
was the whole of it.

"About ihasmail" now sits under Documentation in the account menu, where
somebody looking for what this thing is would actually go.
2026-08-30 14:17:02 -07:00
Coffey Labs ecc030aa3f Merge pull request #128 from LINUXexpert-org/deploy-dry-run-needs-no-terminal
Let a dry run answer without a terminal
2026-08-30 14:13:14 -07:00
jcoffey-dev 8844fc9836 Let a dry run answer without a terminal
The confirmation ran before the dry-run check, so a dry run over SSH was
refused for having no terminal to confirm on -- and the refusal came out
instead of the report it was asked for. Nothing was going to be deployed
either way: it was asking whether to go ahead with something that was not
going to happen.

Print the report, stop there for a dry run, and gate only the real thing
on the confirmation. The hold list still refuses a held commit under
--dry-run, since that is an answer a dry run should give.

--help printed a fixed line range, which the header edit above would have
clipped. Print the leading comment block itself instead.
2026-08-30 14:11:26 -07:00
Coffey Labs ef7823d6de Merge pull request #127 from LINUXexpert-org/blob-download-compressed-length
Send the length of the bytes we are actually sending
2026-08-30 13:58:24 -07:00
jcoffey-dev 8d475e2b07 Refuse to save a script we only partly read
The transport fix stops the truncation that caused #76, but the save path
had no answer for a baseline that arrives incomplete. It is neither
unknown nor empty, so every existing guard passes it through: it parses
into a shorter rule list that looks exactly like a script with fewer
rules, and saving writes that back over the real one.

Check the script against the shape the generator emits instead. Every
rule comment parses, every enabled rule has an if and a closed body under
it, every block ends with a blank line. Structural rather than a
re-serialize-and-compare, so a script written by an older version whose
serializer differed is still editable.

The rule editor reports a short script as unreadable rather than showing
the rules that happened to parse, since a list that looks complete over a
script that is not is the most dangerous thing it could offer.

A cut at the end of a complete rule block is still a valid shorter script
and cannot be told apart from one; that residual is the proxy's to cover.
2026-08-30 13:56:12 -07:00
jcoffey-dev 0277b5b6a8 Send the length of the bytes we are actually sending
A gzip response is decompressed before the blob proxy sees the body, but
its content-length still describes the compressed bytes. Copying that
header onto the longer body made the browser stop reading that many bytes
in and call the download complete, so files arrived truncated with nothing
reporting a failure.

It took a hop that compresses to show up, and one that only compresses
above a threshold to look like a race: a Sieve script stayed intact for two
rules and came back cut off mid-rule once the third pushed it past 1 KiB.

Ask upstream for identity, and forward no length at all rather than one
that describes different bytes.
2026-08-30 13:46:05 -07:00
LINUXexpert.org 22c8eb4af6 Merge pull request #126 from LINUXexpert-org/copyright-coffey-labs
Change the copyright holder to Coffey Labs
2026-08-30 01:31:15 -07:00
jcoffey-dev bd3c4bf964 Change the copyright holder to Coffey Labs
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.
2026-08-30 01:29:17 -07:00
LINUXexpert.org 7eca17665a Merge pull request #124 from LINUXexpert-org/work/coc-contact
Say where to report a code of conduct violation
2026-08-29 00:29:33 -07:00
jcoffey-dev b6327ffb98 Say where to report a code of conduct violation
The Contributor Covenant ships its enforcement section with a placeholder
for the contact address, and this copy never filled it in. The sentence
whose entire job is to tell someone where to report harassment read:

    reported to the community leaders responsible for enforcement at
    .

So the document existed, scored on GitHub's community profile, and
answered the question it was there to answer with a full stop. Anyone who
needed it would have had to go looking somewhere else, at the moment they
were least inclined to.

Uses the same obfuscated address as SECURITY.md and CONTRIBUTING.md, so
there is one contact for the project rather than a second one to keep in
sync. Found while porting this file to cairnobs, which is about to go
public and would have inherited the same gap.
2026-08-29 00:26:17 -07:00
jcoffey-dev d8fc47d765 Clean up contributor metadata 2026-08-28 15:18:05 -07:00
LINUXexpert.org ddd1bbf9b3 Merge pull request #123 from LINUXexpert-org/untrusted-device-mode
Ask whose computer this is, and believe the answer
2026-08-28 14:20:40 -07:00
jcoffey-dev 0b01956535 Ask whose computer this is, and believe the answer
Sign-out never cleared local storage. It stopped push, flushed settings and
removed the subscription -- that last one reasoned explicitly that a browser
left holding someone's mail becomes somebody else's next -- and then left the
settings cache and the recently-addressed list on disk. That list is other
people's addresses, and nothing ever removed it.

Clearing it on sign-out is now unconditional, because lending a laptop is the
same exposure as a public machine, only quieter. The keep-list is short and
deliberate: lastUser, which only a trusted device writes; the trust flag; and
the random push device id. Everything else goes, so a key added later is
forgotten by default rather than by nobody having thought about it.

"Keep me signed in on this device" defaulted to true, which assumed the answer
most costly to get wrong -- someone on a library machine got a thirty-day
cookie unless they noticed a ticked box. It now asks whose computer this is,
defaults to not yours, and says what each answer does. Untrusted means a
session cookie, nothing written locally, no push subscription, and a five
minute idle sign-out.

The idle timer is there because the alternative does not work: custom
beforeunload text was removed from browsers years ago, and no event fires at
all for walking away from a signed-in screen, which is the case that matters.
A timer needs nobody's cooperation.

Reads are gated as well as writes, since a machine trusted once still has the
residue; an untrusted sign-in purges it outright. The wire keeps calling this
`remember` -- it is persisted in SESSION_FILE, and renaming it would invalidate
every session file on upgrade for a change of vocabulary.

Verified in a browser against the mock, not only in tests: untrusted sign-in
leaves localStorage empty through a full session including folder expansion;
trusted writes settings, recent and lastUser as before; sign-out clears recent
and settings while keeping lastUser; an untrusted sign-in afterwards clears
even that.
2026-08-28 14:15:15 -07:00
LINUXexpert.org 045dda109b Merge pull request #122 from LINUXexpert-org/roadmap-2fa-issue-closed
Say where the 2FA entry came from, not that something is tracking it
2026-08-28 12:02:42 -07:00
jcoffey-dev 1c678fabed Say where the 2FA entry came from, not that something is tracking it
The roadmap's preamble promised that anything with an issue number was tracked
in the issue tracker, and the two-factor entry ended in a bare "Reported as
#75". That issue was closed as completed on 2026-08-26, so the one entry the
promise applied to was the one it was wrong about: a reader following the link
finds a closed ticket and has to guess whether the work went with it.

It did not. #75 reported a sign-in refused with nothing but "Invalid
credentials", and that bug was fixed -- the message now says what is happening
and points at app passwords. The OAuth work the report uncovered stayed behind
on this page, which is exactly the case the preamble had no room for.

So the preamble now says an issue number records where an entry came from
rather than where it is tracked, and the entry says plainly that #75 is closed,
what closing it fixed, and that there is no ticket to watch for the rest.
2026-08-28 12:00:38 -07:00
LINUXexpert.org 6bbe2448c4 Merge pull request #121 from LINUXexpert-org/immutable-positioning
Lead with what makes it different
2026-08-27 23:14:50 -07:00
jcoffey-dev 490b15e8c6 Lead with what makes it different
"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.
2026-08-27 23:12:30 -07:00
LINUXexpert.org f2e0cb6326 Merge pull request #120 from LINUXexpert-org/reload-without-waiting-for-the-user
Notice a new build without being told
2026-08-27 22:55:00 -07:00
jcoffey-dev 8f9d253939 Reload even when there is an unsent draft
Holding the reload back while a compose window had unsaved text protected the
text, but it meant a tab could sit on a build the server no longer runs for as
long as someone left a draft open -- which is not automatic, and automatic is
the point.

So the reload is unconditional once the versions differ, and this will
sometimes take an unsent draft with it. The trade is deliberate: a tab talking
to a server it does not match is the worse failure, and it fails quietly.
2026-08-27 22:51:33 -07:00
jcoffey-dev fedd6ed161 Notice a new build without being told
Checking only on a 401 was not automatic, just deferred. It needs the tab to
make a request, so one left open and idle went on running the old build until
somebody touched it -- which is exactly the thing that cannot be relied on.

The obvious signal turned out to be the wrong one, and testing is what showed
it. A deploy kills the EventSource behind /api/events, which looks like the
perfect cue, except it arrives while the container is still being replaced: the
check that follows cannot reach the server, fails, and is never retried.
Waiting for the stream to come back instead does not work either, because the
session died with the old container, so the reconnect is answered with a 401
and never reaches "connected" at all. The drop is still watched, since it costs
nothing and sometimes lands late enough to be useful, but nothing depends on it.

What the guarantee rests on is a slow poll while the tab is visible, plus a
check when it becomes visible again. Neither cares what the stream is doing or
whether anyone is at the keyboard. /api/health touches nothing upstream, so a
minute between checks costs one small request per open tab.

Reloading is now something that happens to people rather than something they
ask for, which makes it able to destroy work. A compose window holds text that
has not reached the server, and after a deploy it cannot be saved at all --
the session went with the container. Reloading would be the difference between
signing in again and pressing send, and losing what was written. So anything
holding such state can say so, and compose does; the tab stays on the old build
until the draft is dealt with, and catches up on the next check afterwards.
2026-08-27 22:45:16 -07:00
LINUXexpert.org a3dc7e017c Merge pull request #119 from LINUXexpert-org/reload-on-new-build
Reload when the server is running a newer build
2026-08-27 22:24:18 -07:00
jcoffey-dev e327df818a Reload when the server is running a newer build
Being signed out and picking up a new version are separate things, and only
the first was happening. An immutable instance holds sessions in memory, so a
deploy signs everyone out -- but a 401 only swaps the view to the sign-in form,
client-side. The tab keeps the bundle it already has, and the old JavaScript
goes on talking to the new server until someone happens to reload by hand.

The pieces for fixing it were already there. index.html is served no-cache and
the assets under it are content-hashed and immutable, so a reload is all it
takes; Vite bakes the build's own version in as APP_VERSION; and /api/health
reports the server's. What was missing was something to compare them.

The check runs on a 401 rather than on a timer, which is the moment it matters
and costs one small request. It compares versions rather than reloading on
every 401, so an ordinary session expiry still lands on the sign-in form with
the page intact. And it runs before the sign-in form is shown rather than
after, because reloading a form someone has already started typing into would
throw the password away.

Failing to reach the server is not a reason to throw away what is on screen, so
anything other than a clear answer leaves the page alone. The version that was
reloaded for is remembered for the session, so a server that keeps reporting a
version the bundle does not match -- a stale proxy cache, a half-finished
deploy -- cannot put the tab in a reload loop.
2026-08-27 22:21:32 -07:00
LINUXexpert.org d6aa4d543a Merge pull request #118 from LINUXexpert-org/deploy-immutable-mode
Deploy immutably when asked to
2026-08-27 22:06:06 -07:00
jcoffey-dev 4cd7b895e9 Deploy immutably when asked to
IHASMAIL_IMMUTABLE=1 runs the container the way the README's "Running
immutably" section describes: read-only root filesystem, no volume, sessions
held in memory. Until now that shape could be run by hand but not deployed --
the run line mounted the data volume unconditionally, so a redeploy would have
quietly put a mutable container back.

The switch is one variable and nothing else. IMMUTABLE=1 is passed to the
server too, which checks the claim rather than believing it, so a half-applied
switch refuses to start instead of looking fine until the next redeploy signs
everyone out. SESSION_FILE is cleared with -e rather than by editing the
environment file, because -e wins over --env-file; that keeps going back a
matter of changing the same one variable:

  IHASMAIL_IMMUTABLE=0 ./ihasmail-deploy.sh --yes

which reproduces the previous run line exactly. The named volume is never
touched in either mode, so the sessions that were in it when the switch was
thrown are still there to come back to.
2026-08-27 22:04:13 -07:00
LINUXexpert.org 37bf96409d Merge pull request #117 from LINUXexpert-org/immutable-session-seam
Let the container run with nothing writable
2026-08-27 21:52:02 -07:00
jcoffey-dev f72c67864e Let the container run with nothing writable
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.
2026-08-27 21:48:23 -07:00
LINUXexpert.org 312a833d78 Merge pull request #116 from LINUXexpert-org/docs-menu-link
Link the documentation from the profile menu
2026-08-27 15:36:51 -07:00
jcoffey-dev 0fe75b280b Link the documentation from the profile menu
docs.ihasmail.org is where installing, configuring and using ihasmail
are explained, and nothing in the app pointed at it. The profile menu is
where someone looks for the things that are about the app rather than
about their mail, so it goes there, above Settings, and opens in a new
tab: reading the docs is something you do beside your mail, not instead
of it.

`MenuItem` renders a real anchor when given an href, rather than a button
calling window.open. The browser's own handling of a link comes with it --
middle-click, a modifier-click, "open in new tab", the address on hover,
copying it -- none of which a button offers however carefully it is
scripted, and all of which someone expects of a menu entry that leaves the
app. Items without an href are the button they always were.

It also needed a line of CSS. The global rule for `a` coloured and
underlined the one entry that is a link, so the menu had a blue underlined
item among four plain ones, which reads as a mistake rather than a
distinction.

Verified against the mock: the entry sits above Settings, is an anchor to
https://docs.ihasmail.org with target=_blank and rel=noopener noreferrer,
and computes to the same colour, size and decoration as Settings beside
it.
2026-08-27 15:34:30 -07:00
LINUXexpert.org 05be820be4 Merge pull request #115 from LINUXexpert-org/unknown-mailbox
Say a missing folder is missing, not empty
2026-08-27 15:31:17 -07:00
jcoffey-dev 5a7cc5cc5a Say a missing folder is missing, not empty
A folder id the account does not have rendered the ordinary empty state --
"Nothing here. This folder is empty." That is a claim about a folder that
is not there, so a stale link read as a folder that had emptied itself
rather than one that was gone (#111).

It now goes to the inbox and says why. Inbox is the kinder landing than a
dead end for a bookmark that has outlived its folder, but swapping one
folder for another without a word would be its own small lie, so it does
not do that either.

The condition worth writing a test around is not the unknown id, it is
the one guarding it. The folder list arrives after the first paint, so for
a moment *every* id is unknown, the right one included. Without that gate
this redirects on every cold load, from the folder the reader actually
asked for, and looks exactly like a flaky link -- a worse bug than the one
being fixed and a harder one to see. `isUnknownMailbox` is a small pure
function so that case can be pinned down rather than reasoned about.

Only ever reachable from outside the app, which is why it went unnoticed:
the sidebar links to ids that exist. A bookmark to a deleted folder, or a
folder link passed between accounts, is where it bites.

Verified against the mock: an unknown id lands on the inbox with the
message and a full list rather than an empty one, and a cold load straight
into a real folder stays in that folder with nothing said.

Closes #111.
2026-08-27 15:28:26 -07:00
LINUXexpert.org b4082d5bb2 Merge pull request #114 from LINUXexpert-org/screenshot-recipients
Take a screenshot of the recipient picker
2026-08-27 14:53:10 -07:00
jcoffey-dev 6efac64b37 Take a screenshot of the recipient picker
The site says you can pick recipients by reading the address books rather
than remembering a name. It had no picture of that, and a claim nobody
can see is a claim nobody believes.

Taken from the composer step, where a composer is already open. The
obvious place was a step of its own later in the run, and that failed:
navigating back to the mail list after the run has been through Files
does not reliably render rows within any wait I was willing to give it.
Worth knowing rather than rediscovering -- the earlier inbox step goes to
the same route and is fine, so it is the state left behind, not the
route.

The shot ticks two people before firing, since a picker photographed
empty shows a list rather than a choice.

The filters step still times out waiting for its editor, as it did before
this change. Everything up to it is written; filters.jpg is whatever the
last successful run left. Still undiagnosed, and still not this.
2026-08-27 14:41:16 -07:00
LINUXexpert.org 8cc12b8f56 Merge pull request #113 from LINUXexpert-org/fixture-example-address
Use an example address, and the right name, in the test fixtures
2026-08-27 14:17:38 -07:00
jcoffey-dev a2337f6ad8 Use an example address, and the right name, in the test fixtures
Two things, one of them not what it looked like.

An organizer fixture was built from a real, routable address. Every other
fixture in the codebase uses example.org or example.com, and this
repository is public, so that one was a personal address sitting in
public source for no reason -- the test asserts roles and participation
status and never reads either value. It is [email protected] now.

The names were wrong in the other direction. Three fixtures across two
files said "John Ellis", which is not the maintainer's name; it is
John Coffey. Being a name rather than a routable address, it leaked
nothing, but it was simply incorrect, and incorrect in the sort of place
nobody rereads.

The address and the name are separate questions and got separate answers:
the address is fictional because it is an address, and the name is real
because it is right. A message from [email protected] signed John Coffey
is exactly what these tests mean.

Found while checking, at the maintainer's prompting, whether the repo
leaked anything about the host it runs on. It does not -- the nginx and
deploy files here are the generic examples they claim to be, and the real
ones live in a private repository.
2026-08-27 14:15:00 -07:00
jcoffey-dev e4b6413f46 Use an example address in the participants fixture
One test built its organizer from a real, routable address and a real
name. Every other fixture in the codebase uses example.org or
example.com, and this repository is public, so the odd one out was a
personal address sitting in public source for no reason -- the test
asserts roles and participation status and never looks at either value.

Now [email protected], matching what the rest of the tests already use.

Found while checking, at the maintainer's prompting, whether the repo
leaked anything about the host it runs on. It does not: the nginx and
deploy files here are the generic examples they claim to be, and the real
ones live in a private repository. This was the only thing the search
turned up that was worth changing.
2026-08-27 14:09:12 -07:00
LINUXexpert.org 3c417f070c Merge pull request #112 from LINUXexpert-org/screenshot-files
Take the files screenshot with the others
2026-08-27 13:56:40 -07:00
jcoffey-dev e3de0bd500 Take the files screenshot with the others
It was the one shot taken by hand, and it outlived two rewrites of the
view it was meant to show -- a picture of a single-pane file list, still
in the docs after the pane grew a folder tree beside it. Nothing was
wrong with the process except that there wasn't one.

The script takes it now, expanding the tree and opening a folder first,
since a screenshot of Files with nothing open is a screenshot of a list
rather than of a file manager.

Anything the docs show should come from the mock. Otherwise it describes
whatever the app looked like on the day somebody had a screenshot tool
open, which is how this one got three versions out of date without
anybody noticing.

The other shots in docs/screenshots are refreshed by the same run. The
filters step timed out waiting for its editor, so filters.jpg is the
older one; that shot is untouched by anything here and the failure is not
diagnosed, which is worth knowing before the next person runs this and
assumes they broke it.
2026-08-27 13:54:15 -07:00
LINUXexpert.org 06b89111df Merge pull request #110 from LINUXexpert-org/mailbox-sharewith
Ask for shareWith on mailboxes too
2026-08-27 13:35:06 -07:00
jcoffey-dev 4c4821b5db Ask for shareWith on mailboxes too
The third store fetching everything by asking for nothing. Same cause as
the calendars and address books a commit ago: Stalwart does not return
`shareWith` unless a client names it, so mail folders never looked shared
either.

This one has a narrow but real consequence. Sharing a mail folder is
withdrawn, because Stalwart stores the share and never delivers it, and
the only way left to clear one already made is the "Stop sharing" entry
-- which appears only when a folder looks shared. Without the property it
never did. The escape hatch built for exactly that situation could not be
reached from the situation it was built for.

Found by looking for the rest of them rather than waiting for the next
report: `ids: null` with no `properties`, across the app. The others it
turned up -- Sieve scripts, identities, the vacation response, quotas,
participant identities, push subscriptions -- have no `shareWith` to
lose, so mailboxes were the last.

The mock hides it here as well now, so all three are honest.
2026-08-27 13:33:04 -07:00
LINUXexpert.org e14fc36785 Merge pull request #109 from LINUXexpert-org/ask-for-sharewith
Ask for shareWith, or the server does not send it
2026-08-27 13:27:46 -07:00
jcoffey-dev 506865ca67 Ask for shareWith, or the server does not send it
Nothing was ever badged as shared, "Stop sharing" never appeared, and the
share dialog opened on "not shared with anyone yet" over live shares. The
sharing itself was fine. The client simply never learned about it.

Stalwart does not return `shareWith` unless a client names it. A
`Calendar/get` or `AddressBook/get` with no `properties` comes back
without the field at all -- not null, not empty, absent -- confirmed
against the live 0.16.19 on a calendar and an address book that really
were shared with another account. Omit the list and there is no
`shareWith`; name it and the sharee is right there.

Both stores fetched everything by asking for nothing, and got less than
they would have by asking. They name the properties now.

The dialog is the part worth dwelling on. It seeds itself from the
`shareWith` it was handed, so it has been showing an empty sharee list on
collections that were shared -- the one screen whose whole job is
managing sharing, and the one most confidently wrong about it. Someone
looking there to see who had access, or to take it away, was told there
was nobody.

Files never had this: `fileNodeProps` has named the property since file
sharing went in, for the same reason and after the same surprise. The two
stores that fetched with `ids: null` and no properties are the two that
were blind.

The mock now omits it the same way. One that hands `shareWith` over
unasked lets a client that never asks look correct everywhere except
against a real server, which is exactly how this got here.

Verified against that mock: sharing a calendar puts the sharee in the
store, badges the row, adds "Stop sharing", and the dialog lists them --
while a `Calendar/get` with no properties still comes back without the
field, so the mock is now failing the way the server does.
2026-08-27 13:25:00 -07:00
LINUXexpert.org f83157464c Merge pull request #108 from LINUXexpert-org/stop-sharing
Let the owner stop sharing a calendar or an address book
2026-08-27 13:13:21 -07:00
jcoffey-dev 9544fa5f12 Let the owner stop sharing a calendar or an address book
Revoking a share meant opening the share dialog, removing each person
from it in turn, and saving. That is the right tool for changing who has
access and the wrong one for withdrawing it altogether, which is the more
urgent of the two and the one someone is likely to want in a hurry.

Both now offer "Stop sharing" in the context menu, which clears the lot
after a confirmation saying how many people lose access. It appears only
when there is something to revoke, so the menu says whether a thing is
shared as well as offering to change it.

A calendar also says it is shared now. Address books have carried that
badge since they gained sharing; calendars never did, so the only way to
find out was to open the dialog and look -- which for the owner of a
dozen calendars means opening a dozen dialogs.

Both go through the existing update paths, so a server that refuses is
reported rather than swallowed.

Verified against the mock, both kinds: sharing one shows the badge and
adds the entry, confirming clears `shareWith`, the badge goes, and the
entry disappears with it since there is no longer anything to stop.
2026-08-27 13:11:21 -07:00
LINUXexpert.org 298264aeb8 Merge pull request #107 from LINUXexpert-org/picker-loads-contacts
Load the contacts the recipient picker is meant to show
2026-08-27 13:03:30 -07:00
jcoffey-dev 3a2f60189f Load the contacts the recipient picker is meant to show
The picker opened on "No contacts in this address book" -- about an
address book with contacts in it. Nothing was wrong with the button, and
that is why it read as one: it opened, correctly, onto nothing.

Contacts are fetched on demand. `loadAll` runs when the Contacts view
mounts, and `suggest` kicks it off itself, which is why autocomplete has
always worked from anywhere. The picker did neither, so opening a
composer without having visited Contacts first -- which is most of the
time, and every time in a fresh tab -- showed an empty list over a full
account. Anyone who had been to Contacts that session saw it work, which
is the sort of difference that reads as browser-specific when it is not.

It asks for them now, and says it is loading rather than that there are
none.

While here: the picker decided which shared books to offer on
`isSubscribed` alone. Stalwart refuses that flag on a book shared
read-only, so those are recorded in settings instead -- for an address
book it is the *only* record -- and filtering on the server's flag left
every shared book out of the picker while the sidebar showed it. Both now
ask the same question.

Verified against the mock from a genuinely cold store -- cards emptied,
`loaded` false, opening the picker as the first thing that wants them:
eight rows, from the reader's own book and a shared one, where before
there were none.
2026-08-27 13:01:01 -07:00
LINUXexpert.org 31239ed9be Merge pull request #106 from LINUXexpert-org/keep-full-copies
Keep the full copy of an email the server says changed
2026-08-27 12:44:58 -07:00
LINUXexpert.org 6a98dd22fd Merge pull request #105 from LINUXexpert-org/mock-reports-changes
Make the mock report what changed
2026-08-27 12:43:04 -07:00
jcoffey-dev 25b51069a9 Keep the full copy of an email the server says changed
The reading pane emptied and refilled when a thread was marked read. On
an HTML message that is a flash to the app's own background and out
again, which is what remained of #100 once the message view stopped
rebuilding its body.

`applyChanges` dropped `fullIds` for every email the server reported as
updated, so the next read would fetch it again. But the reading pane
renders only the emails it holds in full. Dropping one took the message
out of the open thread until the refetch at the end of the same function
put it back -- and marking as read causes exactly that, because the
server echoes our own change back as an update. The gap is a round trip,
which is why it is plainly visible against a real server.

Nothing is lost by keeping the copy. RFC 8621 makes every property of an
Email immutable except `keywords` and `mailboxIds` -- the id is derived
from the content, so a body cannot change beneath one -- and both are in
LIST_PROPS, which the refresh immediately below merges over the cached
copy. The eviction only ever cost the message its place in the thread.

On the evidence, since I got this wrong once already by trusting a
reproduction that did not exist. This is reasoned from the code and
matched against the reported symptom -- "the pane empties and comes
back", which is precisely what removing an email from the thread and
refetching it looks like. It is not backed by a local reproduction: the
mock never ran this path at all, because `Email/set` announced nothing
and `Email/changes` always answered empty. That is being fixed
separately, and it is why every check made here has been against a server
that never reported the change being made.
2026-08-27 12:42:38 -07:00
jcoffey-dev cd402a6ce4 Make the mock report what changed
Two silences, and between them the whole change-reconciliation path was
untestable here.

`Email/set` never announced anything. A real server pushes a state change
after a set and the client acts on it -- `Email/changes`, then the store
deciding what to do with the answer. The mock said nothing, so that path
simply did not run.

And `Email/changes` returned three empty arrays whatever had happened. So
even when it was asked, the answer was that nothing had changed.

Together they meant every version of the mark-read code has been checked
against a server that never reported the change being made. That is how
#100 reached production, and why the fix for it could be verified in the
message view -- where the flicker partly was -- while whatever remains
stayed invisible, because the code that runs when the server answers back
has never run here at all.

The mock now records what each set created, updated and destroyed against
the state it happened in, answers `Email/changes` from that log, and
broadcasts afterwards the way Stalwart does.

This is a mock change on its own. It fixes nothing and is not meant to:
it makes a path testable that was not, which is the prerequisite for
finding what is left of #100 rather than guessing at it. I had a theory
about `fullIds` eviction and reverted it -- three attempts to reproduce
the symptom against this mock failed, which was itself the finding.
2026-08-27 12:40:09 -07:00
LINUXexpert.org 453a62115b Merge pull request #104 from LINUXexpert-org/stop-rebuilding-the-message-body
Stop rebuilding the message body when it is marked read
2026-08-27 12:20:33 -07:00
jcoffey-dev c0fc0083ff Stop rebuilding the message body when it is marked read
Marking a thread read redrew the message pane: the mail vanished and came
back, white to dark to white on an HTML message that brings its own
colours, half a second after the reader started reading it. Worst with
auto-mark set to "immediately", where it happens the moment the thread
opens (#100).

The pane was not re-mounting. The *body* was being thrown away and built
again, and the reason is one dependency.

`HtmlBody` writes the message into a shadow root in an effect, and that
effect had the click handler in its dependency list. The handler is a
`useCallback` over `onShowImages`, which the parent passed as an arrow
created inline, so it was a new function on every render -- and therefore
the effect ran on every render, and every render replaced the rendered
message with an identical one. Marking as read is exactly such a render:
the store hands back a new email object and the thread re-renders.

The listener now lives in its own effect. It is attached to the shadow
root rather than to the contents, which survives the rewriting anyway, so
a handler that changes identity costs a listener swap and nothing else.
`onShowImages` is stable now too, but the split is the fix: it is what
makes the body immune to the next handler that changes.

This also stops the quoted-text toggle collapsing. `setQuoteOpen(false)`
lives in the same effect and had been resetting on every render, so
expanding a quote and waiting for the timer put it away again.

Measured rather than watched, since a flicker is exactly the thing an eye
will agree with you about. Holding a node from inside the shadow root
across the transition, on the same three-message thread with the delay at
0: before, 21 childList mutations on the root and the held node detached
and replaced; after, no mutations at all and the same node still
attached. Clicking a blocked image still reveals remote images, which is
what the moved listener is for.

Closes #100.
2026-08-27 12:17:49 -07:00
LINUXexpert.org 8a3e0b9954 Merge pull request #103 from LINUXexpert-org/remember-added-shares
Remember an added address book when the server will not
2026-08-27 12:08:13 -07:00
jcoffey-dev 5e5bec31b7 Remember an added address book when the server will not
"You are not allowed to modify this address book." That is Stalwart's
answer to a sharee subscribing to a book shared read-only, and it is a
fair one: `isSubscribed` lives on the collection rather than on the
reader, so adding one is a write to the *owner's* account. The identical
write on a shared calendar is accepted. The difference is the server's.

So the flag is still asked for first -- a preference the server holds is
one every client agrees about -- and when it is refused the answer goes
in the reader's own synced settings instead, as `addedShares`, keyed by
account and collection. Either record counts as added, and the rule has
a test of its own because three components ask the question and they
must not drift apart.

Two things about how this hid. The refusal arrives as a *successful*
response with the id in `notUpdated`, so the version that ignored it saw
nothing wrong and the button simply did nothing -- fixed a commit ago,
and it is what turned "the + does nothing in Firefox" into a sentence
from the server. And it cannot be seen from the owner's account at all,
where the write succeeds: it took two browsers signed in as two accounts
to find, which is why it survived every check made from one.

The mock refuses the same write for the same reason. One that accepted
it would have gone on agreeing with the belief that shipped.

Verified against it: adding the shared book is refused by the server,
recorded in settings, and the book moves to "Shared with me" with its
contacts reaching the To field; removing undoes all three; and it
survives a full page reload, which is the point of putting it where the
settings live rather than in this tab.
2026-08-27 12:06:18 -07:00
LINUXexpert.org 04ec57058a Merge pull request #102 from LINUXexpert-org/say-why-subscribe-failed
Say so when the server refuses a subscribe
2026-08-27 11:34:45 -07:00
jcoffey-dev 3416a41de9 Say so when the server refuses a subscribe
Adding a shared address book did nothing in one browser and worked in
another. The button was not broken; the refusal was invisible.

Subscribing is the one call in the app that writes to somebody else's
account, so it is the one a perfectly healthy server is entitled to say
no to -- and JMAP says no to a `/set` by answering successfully with the
object listed in `notUpdated`. Neither subscribe method looked. The
promise resolved, the code carried on, the re-read came back unchanged,
and the row stayed exactly where it was with nothing said.

Every other `/set` in this codebase reads `notUpdated` and raises. These
two were written without it, which is the whole defect: not a wrong
answer, an unread one.

Both now check it and say what the server said, which is the thing that
was missing -- whatever the underlying refusal turns out to be, it can be
read off the screen instead of guessed at from which browser was in
front of you.
2026-08-27 11:32:49 -07:00
LINUXexpert.org d9995cd0b4 Merge pull request #101 from LINUXexpert-org/subscribe-shares
Add shared collections deliberately, and pick recipients from the address books
2026-08-27 11:21:22 -07:00
jcoffey-dev 5f32d3d82c Choose recipients from the address books
Addressing a message worked only if you already knew the name you were
half-way through typing. Autocomplete answers "finish this for me"; there
was no answer to "who is there?", which is the question someone has when
they open a compose window and want the person from the team list whose
surname they cannot summon.

The To row now opens the address books -- from a button beside Cc and
Bcc, where someone thinking about recipients is already looking, and from
the To label itself for anyone who tries that first. Search across every
book or narrow to one, tick as many people as the message needs, and send
them to To, Cc or Bcc. Picking for a field that is hidden opens it, since
a Bcc dropped somewhere invisible is worse than no Bcc.

Every address is its own row rather than every person. Somebody with a
work address and a personal one is a choice the writer has to make, and a
picker that listed the card and quietly took the first address would be
making it for them.

Shared books are in it on the same footing as the reader's own -- that
being the point of having added them -- with the account named on each
row, so it is never a mystery whose list a name came from. Books that
have not been added contribute nothing, the same rule the To field
already follows.

Verified against the mock: the picker lists the reader's contacts and the
shared book's, each row naming its source; ticking one of each and
choosing Cc opens the Cc row with both in it.
2026-08-27 11:19:45 -07:00
jcoffey-dev 0215255280 Add a shared calendar or address book, rather than being given it
An account linked for its files also offered its calendar and its address
book, and neither had been shared. That was not ihasmail inventing them:
asked about the other account, the live 0.16.19 returns every calendar
and every book it holds, each with full rights -- read, write, share,
delete, all true. There is nothing in the rights to tell "shared with me"
from "reachable at all", because the server does not distinguish them.

`isSubscribed` does, and it is the field JMAP has for exactly this: it
came back false on all of them. So a shared calendar or book is listed
under "Shared with me" once the reader has added it, and under "Available
to add" until then, with one button either way.

Nothing unsubscribed contributes anything. A calendar that has not been
added draws no events, and a book that has not been added lends no cards
to the To field -- which is the one that mattered most, since it is the
difference between offering a colleague's contacts and offering a
stranger's without anyone having asked.

The mock's shared calendar and address book now arrive unsubscribed, the
way the real server hands them over, so the adding is exercised rather
than skipped; and its `Calendar/set` and `AddressBook/set` route by
account, since subscribing to somebody else's is a write to their
account and the mock had nowhere to put it.

Verified against the mock: the shared calendar sits under "Available to
add" with no events in the grid, adding it moves it to "Shared with me"
and its events appear, removing it undoes both; and `suggest("katherine")`
finds nothing until the shared book is added, then finds her.
2026-08-27 11:16:30 -07:00
LINUXexpert.org 8d55652587 Merge pull request #99 from LINUXexpert-org/shared-calendars
Shared calendars in the calendar, and no more account switcher
2026-08-27 11:02:48 -07:00
jcoffey-dev 270fb3d32c Shared calendars in the calendar, and no more account switcher
Three things from using it on two real accounts.

A calendar shared with you never appeared. Nothing was wrong with the
share -- the calendar had nowhere to be shown. Calendars loaded from one
account and one only, so the sharer's were reachable solely by switching
the whole app to their account, which is the door being closed below.
They now sit under "Shared with me" beside the reader's own, in their own
colour, with their events in the grid and a click to hide them like any
other calendar.

Their events go through `instancesIn`, the one funnel every view already
reads, so month, week, day and agenda got them without being touched.
Events and calendars from another account are keyed by account as well as
id, and hiding one is remembered under the same key: an id means nothing
outside the account holding it, and two accounts sharing an id is
ordinary rather than unlucky.

An account that shared nothing was listed in Files as though it had.
Every non-personal account was offered on the reasoning that its folders
could speak for themselves -- but an account whose *calendar* was shared
has no folders to speak with, and appeared as an invitation to open an
empty pane. Each is now asked for one file before being listed, and
silence is taken for an answer.

And the account switcher is gone from the profile menu. It existed to
reach what other people shared and was the wrong door: it moved the whole
app to somebody else's account, and since Stalwart advertises every
capability on a shared account, mail, calendar and contacts went with it
and were refused. Everything it was for is now in the module the share
belongs to, found without anyone needing to know an account was involved.

What this does not prove is that Stalwart delivers a calendar share at
all. The mock says the client handles one, which is the half that was
missing; whether the server behaves like address books, which work, or
like mail folders, which do not, needs the two accounts again.
2026-08-27 10:57:27 -07:00
LINUXexpert.org 25fd6404f2 Merge pull request #98 from LINUXexpert-org/shared-address-books
Put address books in the left pane, other people's included
2026-08-27 10:43:20 -07:00
jcoffey-dev 350f4f4197 Put address books in the left pane, other people's included
Address book sharing was withdrawn a few hours ago on a report that it
behaved like mail folder sharing. That was wrong -- it works -- and it is
back, built the way Files is rather than the way it was.

Three things it inherits from Files. Shared books are listed in the app's
own left pane instead of behind an account switch in the profile menu.
The reader's books and other people's sit under separate headings, since
a book belonging to somebody else behaves differently and a single merged
list would be quiet about whose contacts you are reading. And opening
Contacts re-reads the session, so a book shared while the tab was open
turns up without signing out and in again.

The books pane the view kept to itself is gone, and with it the last
module that ignored the sidebar it was given.

The one thing Files does not need: shared contacts have to answer when
somebody types a name into a To field, so they are loaded up front rather
than when a book is opened, and they are offered by `suggest` and found
by `lookupByEmail` alongside the reader's own. Their own cards win a tie,
since a card someone wrote themselves should beat a colleague's copy of
the same person. That is the difference between a shared book you can
look at and one you can use.

Cards from a shared account are held apart from the reader's rather than
merged in, and keyed by account as well as id. Ids are only unique within
an account -- two accounts each having a book `ab1` is ordinary -- and a
flat map would have had one silently replace the other.

The mock grew an address book in its shared account, with contacts in it,
because none of this could be exercised otherwise.

KNOWN-ISSUES records the withdrawal as the mistake it was rather than
leaving it in the history looking like a finding. Mail folder sharing
stays withdrawn: that one really is broken.
2026-08-27 10:40:50 -07:00
LINUXexpert.org 006190d523 Merge pull request #97 from LINUXexpert-org/withdraw-mail-sharing
Stop offering to share mail folders, and let a share be removed
2026-08-27 10:30:36 -07:00
jcoffey-dev 1e2db95577 Stop offering to share mail folders, and let a share be removed
Sharing a mail folder does nothing. `Mailbox/set` takes the `shareWith`
map, `Mailbox/get` reads it back, and the folder never appears for the
account it was shared with -- confirmed on the live 0.16.19 with a folder
shared read-only to another account on the same server, which never saw
it. Stalwart's sharing documentation lists calendars, address books and
file storage; mail folders are not among them. Nothing anywhere reports a
failure, so a client that trusts what it reads back shows the share as
live for ever, which is what happened.

The entry point is withdrawn. Address book sharing goes with it on a
report that it behaved the same way -- not reproduced, and contradicted
by Stalwart's own docs, so that one is expected back; it is out because
offering a share nobody can verify was worse than the gap. Files and
calendars are untouched.

Removing a share was impossible, for a reason worth writing down. The
dialog rendered the list of who a thing was shared with *inside* the
branch that runs when the directory has principals to offer. A server
with `allowDirectoryQueries` off returns none -- that is the default, and
it is how these shares came to be made in the first place -- so the
dialog showed one line of hint and nothing else. The share was there, and
there was no way to see it, let alone remove it. The list is now rendered
whatever the directory says; only the control for adding somebody new
depends on having somebody to add.

So the withdrawn entry points do not strand what they created: a folder
or book already shared still offers "Stop sharing", which is the one
thing you want when the share is invisible everywhere else.

The API was never the problem, which is worth recording since it was the
first guess: `shareWith: null` is accepted and clears the map, tested
against the live server on the stuck folder, which is now unshared.
2026-08-27 10:28:05 -07:00
LINUXexpert.org 88f9474b24 Merge pull request #95 from LINUXexpert-org/shared-with-me
Reach shared folders from Files, not the profile menu
2026-08-27 10:24:33 -07:00
jcoffey-dev 52299ce8ef Attach a file that is already in Files
Attaching meant uploading, even when the file was sitting in the account
already -- picking it off disk again to send the server a copy of what it
was holding.

The composer can now attach from Files. A blob the account can already
see needs no upload at all: an attachment carrying a `blobId` is what a
forward produces, so the send path has always known what to do with one.
Attaching a large file the server is already storing now costs nothing
and takes no time.

A file in an account somebody *shared* is different, because blobs belong
to the account they were uploaded to and a draft in yours cannot
reference one in theirs. Those are fetched and uploaded to your account,
and the picker says so before you attach rather than leaving someone
wondering why one file was instant and another was not.

The picker borrows the Files store, so it browses what Files browses,
shared accounts included, and puts the file manager back where it was on
the way out -- a detour through somebody's shared folder to find an
attachment should not leave Files somewhere else afterwards.

Verified against the mock, and worth recording how, because the first
attempt measured nothing: `client.upload` uses XMLHttpRequest, since it
reports progress, so a counter wrapped around `fetch` sees no uploads
whether or not any happen and agrees with you either way. Counted at
XHR instead: attaching one's own file issues no upload, and attaching a
shared one issues exactly one, to the reader's own account.
2026-08-27 10:18:49 -07:00
jcoffey-dev ad94efb65b Reach shared folders from Files, not the profile menu
A folder somebody shared was reachable only by switching the whole app
to their account from the profile menu -- which nobody would think to
look in for files, and which pointed mail, calendar and contacts at them
as well. The server refused all three, so nothing leaked; it was simply
the app claiming to be somewhere it could not go.

Files now lists shared accounts itself, under "Shared with me", and opens
them in place. Only Files moves: `accountId` in its store is the account
being browsed, `ownAccountId` is the reader's, and nothing else in the
app notices.

Which accounts hold shared files cannot be worked out from capabilities.
Stalwart advertises the whole set on a shared account -- mail, calendars,
contacts, sieve, the lot, identical to a personal one, whatever was
actually shared (checked live on 0.16.19, 2026-08-27). That is why
routing alone could never have fixed this, and why the list offers every
account that is not the reader's own and lets its folders answer for
themselves. The mock's shared account now advertises the same full set,
because a mock that quietly advertised only what it shared would agree
with a fix that cannot work.

Shares also went unseen until the next sign-in. They arrive in the JMAP
session, which is fetched once and refreshed only when a session-state
change is pushed to that tab -- so a share granted while the tab was open
stayed invisible, and one removed stayed on offer. That is the two
browsers disagreeing about whether an account still existed. Opening
Files now re-reads the session, throttled, and the section header carries
a refresh for when someone is waiting on a share they have just been
promised.

The sidebar's button on Files was Compose, which wrote mail from the file
manager. It uploads.

Verified against the mock, which grew a second account to make any of
this testable: "Shared with me" lists it, opening it shows its folders
and not the reader's, the header says whose they are, "Back to my files"
returns, and the profile menu is not involved at any point.
2026-08-27 10:13:58 -07:00
LINUXexpert.org 9f4c0c3351 Merge pull request #94 from LINUXexpert-org/fix-account-routing
Keep your own settings out of someone else's account
2026-08-27 10:02:11 -07:00
jcoffey-dev e014521fb6 Keep your own settings out of someone else's account
Switching to an account somebody shared pointed the whole app at it. The
rule was "use the selected account if it can do this", and a shared file
account can, by definition, do files.

ihasmail keeps its settings in the account's Files -- that is what makes
them follow you between devices -- so changing any setting while looking
at somebody's shared folder wrote `settings.json` into *their* storage,
creating the `ihasmail` folder there to do it. Signature images went the
same way, and push registration would have gone to whichever account was
on screen. Reading someone else's data by mistake is bad; writing yours
into theirs is worse, and one line was doing both.

There are two questions, and they had one answer:

  - what am I looking at -- follows the switcher, because switching to a
    shared account is how you read what was shared
  - what is mine -- never does

So `accountFor` keeps the first meaning and `ownAccountFor` is the
second, used by settings sync, signature images and push. A `??
accountId` fallback in `loadStoredSignature` went with it: the reader's
own signature, reached through whoever happened to be selected.

A third rule was hiding in the first. A capability the selected account
does not advertise fell back to the selected account anyway, so a session
naming no primary for something aimed it at whoever was selected --
somebody else. It now answers with nothing, which is honest: the feature
is unavailable, rather than pointed at a stranger.

What this does not settle is whether the mail, calendar and contacts the
switcher appeared to offer were ever really reachable, or only asked for
and refused. That depends on what Stalwart advertises on a shared
account, which needs a look at a sharee's session; if it advertises
capabilities nobody shared, more is needed here than routing.
2026-08-27 09:59:00 -07:00
LINUXexpert.org cf9474ce35 Merge pull request #93 from LINUXexpert-org/fix-tree-on-account-switch
Show the folder tree in a shared account
2026-08-27 09:56:02 -07:00
jcoffey-dev 2360e40733 Show the folder tree in a shared account
Switching to an account somebody had shared showed an empty folder tree.
Their files listed perfectly well; the sidebar beside them was blank,
with nothing to say why.

Switching accounts cleared `nodes` and `children` and stopped there. So
`treeLoaded` stayed true from the account before -- the sidebar only asks
for folders when it is false, and it never asked again -- while `dirIds`
still named the previous account's folders, which no longer resolved
against the cleared `nodes`. An empty tree either way, and no error,
because nothing had failed.

The fields that belong to one account are now named in one place,
`emptyForAccount`, and the test asserts the whole set rather than the
ones that come to mind. The bug was not bad logic, it was a field nobody
remembered when two more were added a commit earlier, and asserting the
set is the only guard that survives the next two.

Found by the person it was built for, on a real share between two
accounts, which is where it was always going to show up: the tree is
built from a query that had already run for their own account, so it
only breaks on the switch.
2026-08-27 09:51:41 -07:00
LINUXexpert.org c9531c577c Merge pull request #92 from LINUXexpert-org/files-tree
A folder tree, and dragging things into it
2026-08-27 09:29:47 -07:00
LINUXexpert.org fb789bc36f Merge pull request #91 from LINUXexpert-org/share-files
Share files and folders with other people
2026-08-27 09:29:20 -07:00
jcoffey-dev f70eb184c2 A folder tree, and dragging things into it
Files had a breadcrumb and a Move to… dialog. Moving anything meant
opening a dialog and walking down the folder you wanted, which is a lot
of ceremony for something every file manager does by dragging, and there
was nowhere to see the shape of the account at all.

There is now a folder tree in the sidebar, beside the mailbox tree it
borrows its look from. Rows in the list and folders in the tree can be
dragged onto any folder in either, and folders dropped from outside are
uploaded with their structure intact.

The tree arrives in a single query. `filter: { nodeType: "directory" }`
returns every folder in the account -- checked against 0.16.19 on
2026-08-27 -- so nothing waits on an expand, and a drag knows every
folder it could land on including ones nobody has opened. It is
deliberately its own request: a filter Stalwart refuses fails with a
request-level 400 that takes every method call in the request with it,
which `{ parentId: null }` does, so a per-level query batched alongside
the listing would blank the whole view rather than just the sidebar.

Two things the writing of this turned up.

The mock ignored the `nodeType` filter the live server applies, so the
tree asked for directories, was handed files as well, and drew them as
folders you could open into nothing. The mock now filters the way 0.16.19
does. The store also filters again on the way in, because a tree that
believes whatever a server sends is a tree that draws files as folders on
the next server that gets this wrong.

And the drag state was per-pane, which cannot work: a drag that starts in
the list has to be recognised by the tree, and the pane that did not
start it never lit up or accepted the drop. Dropping still worked, since
the drop handler re-checks from the drag itself -- which is why this
would have shipped looking fine and been unusable. It lives in the store
now, with the reason written down.

Dropping a folder in goes through `webkitGetAsEntry`, which is
non-standard in name and universal in practice. Its `readEntries` returns
*up to* some entries per call and signals the end with an empty array, so
a single read loses everything past the first batch. Both bounds in there
-- depth, and entries per directory -- exist because a directory tree
from outside the app is not something to take on trust; the test that
covers the second one found the version without it looping for ever.

Verified against the mock: a row dragged onto a folder in the tree lights
the target, is accepted, and moves it on the server; a top-level folder
dragged to All files is refused as the no-op it is; the tree's own menu
creates, renames, shares and deletes; and the tree lists folders only.
2026-08-27 09:19:54 -07:00
jcoffey-dev 6566f4c2d3 Share files and folders with other people
Calendars and address books have been shareable since JMAP Sharing went
in; Files never was, though Stalwart treats file storage as a first-class
thing to share and ihasmail has carried the types for it all along.
`FilesRights` and `FileNode.shareWith` were already declared -- what was
missing was asking for the property, offering the dialog, and saying so
in the list.

Checked against the live 0.16.19 first, read-only, because building a
picker against a mock that agrees with you proves nothing:

  - `FileNode/get` returns `shareWith`, and `myRights` carries all six
    rights, `mayShare` among them and true on one's own nodes. So the
    menu entry has a real right to gate on -- unlike folder sharing,
    which is offered ungated because `MailboxRights` has no such right
  - `Principal/query` answers now that `allowDirectoryQueries` is on:
    six individuals, no groups
  - `ShareNotification/get` is implemented, which is worth knowing for
    later; nothing here reads it yet

The editor preset grants read, add files and edit contents, and stops
there. Rename and delete stay with whoever shared the folder: someone
given a folder to work in should not be able to rename the thing they
were given, or delete it out from under the person who shared it. Both
are still there to tick by hand.

One finding is worth a test of its own, and has one. Stalwart answers
`shareWith` as `{}` for a node shared with nobody, not `null` -- every
unshared node in a live account came back that way. A truthiness test on
the property is therefore true for every node the server has ever
returned, and the badge driven by it would report the whole account as
shared while being, technically, about the right property. `isShared`
counts keys, and the test says why.

Verified against the mock end to end: sharing Documents with a principal
as Editor persists `mayRead`, `mayAddChildren` and `mayModifyContent` and
nothing else, the badge appears on that folder and not on the file beside
it, and re-opening the dialog shows the saved rights rather than an empty
form -- which is what proves `fileNodeProps` is really asking for the
property.
2026-08-27 09:07:13 -07:00
LINUXexpert.org 24ee502532 Merge pull request #90 from LINUXexpert-org/hold-the-opening-scroll
Hold the opening scroll while the conversation settles
2026-08-27 07:13:54 -07:00
jcoffey-dev 650ba0020b Hold the opening scroll while the conversation settles
Opening an already-read conversation stopped 39px short of the bottom,
every time (#89). The messages were all there and one scroll fixed it,
but the pane was not where it meant to be.

The scroll runs in an effect, which is too early. Message bodies go into
shadow roots from the child effects underneath it, and the images in
those load later still, so the pane goes on growing after the scroll has
already happened -- and `scrollIntoView` clamps to the scroll range as it
stands the moment it is called. The read-thread fallback aims at the last
message, which no thread has the room to lift to the top, so that clamp
*is* the whole of the range. Measuring it before the images landed
measured it short.

So the target is now held against the top of the pane while the thread
settles: a ResizeObserver over the children of the scroller re-aligns it
whenever one of them changes height.

The hold ends the instant the reader touches the pane -- wheel, pointer,
touch or any key -- and after two seconds regardless. A pane that
re-scrolls under someone who has started reading is far worse than one
that lands short, so it lets go on the first sign of them rather than
waiting for the content to stop changing.

Verified against the mock, on the same already-read seven-message thread,
eight opens each way: before, all eight landed at scrollTop 96 of a 135
range; after, all eight land at 135. The #87 cases are unchanged -- an
unread message mid-thread still comes to rest flush against the top of
the pane, and a thread whose first message is the unread one still stays
at 0 with the subject in view. Scrolling or pressing a key during the
hold leaves the pane exactly where it was put.

One correction to #89 while I am here: it reported the pane sometimes not
moving at all. That was an artifact of measuring in a background tab,
where Chrome suspends rendering and clamps timers -- the behaviour in a
visible tab is the deterministic 39px above. The issue is real; that one
observation in it was not.
2026-08-27 07:11:16 -07:00
LINUXexpert.org 32227722a7 Merge pull request #88 from LINUXexpert-org/open-thread-at-first-unread
Open a conversation on its first unread message
2026-08-27 06:42:21 -07:00
jcoffey-dev d64249b46d Open a conversation on its first unread message
Selecting a thread put you at the newest message. Anything unread above
that sat off the top of the pane with nothing to announce it, and the
only way to find out was to scroll up -- by which time the auto-mark-read
timer had marked the whole thread read anyway, so scrolling up meant
scrolling up to mail already counted as seen (#87).

Opening at the bottom is right when there is nothing to catch up on and
wrong the moment there is. The pane now opens on the oldest message that
was unread when the thread was opened, and falls back to the newest when
the thread has already been read.

mbunkus's out-of-order case is the one that rules out guessing at a
position. A participant whose server could not connect for hours
delivers a message long after it was written, and it lands in the middle
of a conversation that has already moved past it -- so "second to last",
or any other fixed offset from the end, finds nothing. Reading the
unread set is the only thing that does.

Two cases leave the pane where it is:

  - a single message, which is already the whole pane
  - the first unread being the first message, where the top of the pane
    shows it anyway, together with the subject; scrolling to it would
    push the subject off for nothing

It reads the set captured when the thread was opened rather than live
`$seen` state, for the same reason expansion does (#69): the mark-read
timer must not change the shape of what you are looking at. That also
makes the landing stable, because everything above the first unread
message is a collapsed row of fixed height -- nothing up there reflows
after the scroll.

The mock grows a thread that reproduces it: seven messages with the
unread one second, four more behind it. Verified against it. Opening the
thread lands the unread message flush against the top of the pane at
scrollTop 158; the old scroll to the newest message put it at 445, with
287px of the message -- header, sender and unread bar included -- above
the fold. On a thread whose first message is the unread one the pane
stays at 0 with the subject in view, where before it would have scrolled
333. Once the thread is read, reopening it goes back to the newest
message.
2026-08-27 06:35:26 -07:00
LINUXexpert.org 2d5bda086f Merge pull request #86 from LINUXexpert-org/docs/readme-quick-start
docs: put the Docker quick start back in the README
2026-08-26 19:18:03 -07:00
jcoffey-dev d15f64ada6 docs: put the Docker quick start back in the README
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.
2026-08-26 19:16:04 -07:00
LINUXexpert.org 906d17a48b Merge pull request #85 from LINUXexpert-org/docs/readme-slim
docs: slim the README, split out known issues and roadmap
2026-08-26 19:08:16 -07:00
jcoffey-dev 9618a0278c docs: slim the README, split out known issues and roadmap
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
2026-08-26 19:02:04 -07:00
LINUXexpert.org 486ab2f0d0 Merge pull request #84 from LINUXexpert-org/remove-2fa-login-entry
Remove the two-factor entry point from the login form
2026-08-26 16:44:07 -07:00
jcoffey-dev 1527ebffc8 Remove the two-factor entry point from the login form
The field never worked here: Stalwart takes a TOTP code only through an
OAuth flow, so a client posting a username and password had nothing to
send it to. Offering the button advertised a feature the login path
cannot honour, so it comes out until the flow works end to end.

The login store still takes a totp argument and the server still accepts
one; the form now passes an empty string, which the server reads as no
code given. A failed sign-in no longer reveals the field, and the
invalid-credentials message drops its mention of a verification code.
2026-08-26 16:41:51 -07:00
LINUXexpert.org 4dd46b156c Merge pull request #83 from LINUXexpert-org/remove-2fa-setup
Stop offering to turn two-factor authentication on
2026-08-26 16:23:27 -07:00
jcoffey-dev c6db19de19 Stop offering to turn two-factor authentication on
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.
2026-08-26 16:21:27 -07:00
LINUXexpert.org 96ec790d58 Merge pull request #82 from LINUXexpert-org/hide-identities
Hide identities from the compose picker
2026-08-26 15:44:55 -07:00
jcoffey-dev 133036a6c5 Hide identities from the compose picker
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.
2026-08-26 15:36:35 -07:00
LINUXexpert.org 72c409f0f5 Merge pull request #81 from LINUXexpert-org/fix-thread-collapse
Stop the reading view rearranging itself as you read
2026-08-26 15:33:59 -07:00
jcoffey-dev d4b39c06d6 Stop the reading view rearranging itself as you read
Opening a conversation with several unread messages showed them all
expanded, each with its unread bar. The moment the auto-mark-read timer
fired, every one of them collapsed except the last, and the bars
vanished -- so the messages you had just been given were taken away
again, and the only record of which ones they were went with them (#69).

Both came from the same place: expansion and the bar were derived from
`$seen`, live. Marking read on the server changed what the view thought
it was looking at.

Marking read is not the problem. Opening a thread is the signal that you
are reading it, and mbunkus was explicit that turning the setting off is
not the answer he wants. What was wrong was letting a change *this view
caused* alter its own shape underneath the reader.

The thread now remembers which messages were unread when it was opened,
and uses that for expansion and for the bar. The set only grows while a
thread is open -- a message arriving unread joins it -- and is discarded
on the way to another thread. The server still gets marked read on the
timer, exactly as before, and the message list still updates.

It is accumulated during render rather than in an effect. It is derived
purely from the messages already in hand and adding an id twice does
nothing, while an effect would repaint a frame later -- which is the
flicker this exists to remove.

Verified against the mock with markReadDelay at 0, the harshest setting,
where the timer fires immediately: six seconds after opening a
three-message thread, the server reports all three seen while the view
still shows all three expanded with their bars. Before, two of the three
would have collapsed in the first instant.
2026-08-26 15:28:51 -07:00
LINUXexpert.org 6e58c22807 Merge pull request #80 from LINUXexpert-org/fix-keyboard-delete
Move focus off a row that has been deleted
2026-08-26 15:18:08 -07:00
LINUXexpert.org 6e59f59d18 Merge pull request #79 from LINUXexpert-org/fix-from-select-dark
Theme the dropdown a native select paints for itself
2026-08-26 15:17:17 -07:00
LINUXexpert.org c647744470 Merge pull request #78 from LINUXexpert-org/fix-sieve-overwrite
Never overwrite filters we could not read
2026-08-26 15:17:13 -07:00
jcoffey-dev 2e33b4c467 Move focus off a row that has been deleted
Two complaints in #71, one cause. Deleting from the keyboard left
`focusId` pointing at a row that was no longer in the list, and nothing
moved it.

The confirmation appearing "every other message": `targetIds()` falls
back to the focused id, so the second `#` re-targeted the message the
first one had just deleted. The optimistic update had already moved that
message into Deleted Items, so it read as a permanent delete -- and a
permanent delete always confirms, whatever "Confirm before deleting" is
set to. The dialog was correct about the message it was asked about; it
was asked about the wrong one.

`k` jumping to the top: `moveFocus` reads `ids.indexOf(focusId)`, which
was -1 for the departed row, and -1 is treated as "before the first
row". Adding -1 to that clamps to 0.

Both explain why the mouse was fine: clicking sets focus to a row that
exists.

Focus now moves to whatever slid into the deleted row's place, honouring
"After archiving or deleting" -- the row below by default, the one above
when set to newer -- and clears when the folder empties. `moveFocus`
also no longer reads a missing row as index 0; it falls back to where
the list thinks it is.

Verified in the browser against the mock, since arithmetic tests cannot
prove the wiring: with confirmation off, two deletes in a row both go
through silently, focus stepping e4 to e5 to e6 as rows close up; then
`k` moves up exactly one instead of to the top of the list.
2026-08-26 15:15:48 -07:00
jcoffey-dev 087c856a46 Theme the dropdown a native select paints for itself
The From picker's drop-down rendered a light background under light
text, unreadable in any dark theme (#70).

A native <select>'s popup is painted by the browser from the element's
own colours, not the page's. `.from-select` is deliberately transparent
so it sits flush in the composer's From line, which left the popup with
no background of its own: the browser drew a light one while the text
kept the app's light foreground.

Fixed by styling `option` rather than the control, so the popup gets a
background without the closed select gaining a box. Verified: the select
stays transparent, the options are now --bg-elev on --fg, which is
12.5:1 where it was light on light.

Scoped to every select rather than this one. Nothing in the app styled
options anywhere, so this was not one broken dropdown but the first one
anybody happened to open in the dark -- and the next transparent select
would have arrived with the same bug.
2026-08-26 15:09:30 -07:00
jcoffey-dev 9c37af7b07 Never overwrite filters we could not read
Adding a filter from a message reported success while the script on the
server never held more than two rules (#76). Rules were being destroyed,
and the confirmation was a lie.

Three links, each defensible alone:

  1. load() recorded a *failed* blob fetch as `contents[id] = ""`.
  2. sieveToRules("") returns [] -- "this script has no rules", which is
     indistinguishable from "we could not read this script".
  3. Saving rewrites the whole script from that baseline, so every rule
     already in it was deleted. The write itself succeeded, which is why
     the UI said so.

No fetch failure was even required: rules() did `contents[id] ?? ""`, so
a script whose content had not loaded yet read as empty too. And
saveScript cached the content it had just written and then called
load(), which replaced the whole map -- discarding it if the refetch
came back short.

The fix is to keep "unknown" and "empty" apart at every step:

  - a failed fetch leaves the key absent rather than storing ""
  - load() merges rather than replacing, so a reload cannot throw away
    what saveScript just wrote
  - rules() returns null for content it does not have, which every
    caller already treats as "do not touch this script"
  - saveRules refuses outright when the baseline is unknown. Refusing is
    recoverable; overwriting is not.

rules() now also reports whether the script was read, because "written
by hand" and "could not be read" want different advice -- one is
permanent, the other is a reload away, and telling someone the wrong one
sends them hunting for a problem they do not have.

Ruled out on the way: the rule codec round-trips fine, eight rules in
and eight out. sieveToRules reads the `# rule:` JSON comments rather
than parsing Sieve, so the generated script's shape was never the issue.
2026-08-26 15:03:53 -07:00
LINUXexpert.org 450a38f7cd Merge pull request #77 from LINUXexpert-org/totp-honest-failure
Stop pretending the two-factor field can work
2026-08-26 14:57:10 -07:00
jcoffey-dev d98c425a9a Stop pretending the two-factor field can work
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.
2026-08-26 14:46:08 -07:00
LINUXexpert.org b37c422e7d Merge pull request #74 from LINUXexpert-org/push-limits-doc
Say what "even when ihasmail is closed" actually means
2026-08-26 14:15:44 -07:00
jcoffey-dev 7726665a48 Say what "even when ihasmail is closed" actually means
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.
2026-08-26 14:13:19 -07:00
LINUXexpert.org 99e98f82f3 Merge pull request #72 from LINUXexpert-org/fix-emailpush-filter
Send a filter the server can read
2026-08-26 14:03:52 -07:00
jcoffey-dev 2263aa494f Send a filter the server can read
Enabling background notifications failed with "Invalid filter". The
subscription asked to be notified about mail matching:

    filter: { inMailbox: null, notKeyword: "$seen" }

`inMailbox: null` meant "the inbox" in my head and nothing at all to
Stalwart, which needs a mailbox id there. It refused the whole
subscription, so the feature did not work at all for anyone who tried
it.

The Inbox's id is now passed in and used. Where it is not known the
condition is left out rather than sent empty: notifying more widely is a
worse default than filtering to the Inbox, but it is a working one, and
sending a malformed filter is not a fallback.

Two reasons this got out, both worth fixing rather than just the bug:

  - The tests checked the properties list and its ordering, and never
    looked at the filter. There is now one that walks every condition
    and fails on a null or undefined value, for both the known-inbox and
    unknown-inbox cases.

  - The mock accepted it happily, so nothing local disagreed with the
    code. It now refuses a filter condition with a null value and
    answers "Invalid filter.", which is what the live server said.
    Reproduced: the old payload is rejected, the new one accepted.
2026-08-26 14:01:56 -07:00
LINUXexpert.org d7e9e94794 Merge pull request #68 from LINUXexpert-org/web-push
Notifications that arrive when ihasmail is closed
2026-08-26 13:54:18 -07:00
jcoffey-dev 96bc7b53d7 Notifications that arrive when ihasmail is closed
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.
2026-08-26 13:51:09 -07:00
LINUXexpert.org e2f17f6f49 Merge pull request #66 from LINUXexpert-org/deploy-image-retention
Keep the last few images, and record delete-all-spam as verified live
2026-08-26 12:17:48 -07:00
jcoffey-dev e4915dd496 Keep the last few images, and record delete-all-spam as verified live
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.
2026-08-26 12:15:51 -07:00
LINUXexpert.org e45c43100f Merge pull request #65 from LINUXexpert-org/junk-delete-all
Delete all spam, and call folders what the server calls them
2026-08-26 12:04:18 -07:00
jcoffey-dev 18e493bcd1 Delete all spam, and call folders what the server calls them
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".
2026-08-26 12:00:42 -07:00
LINUXexpert.org 34eedfa9af Merge pull request #64 from LINUXexpert-org/theme-ihasmail
Add the ihasmail theme, and make it the default
2026-08-26 11:39:56 -07:00
jcoffey-dev 95e56303f7 Pin the theme settings as account-level, not per-browser
Both `theme` and `lastDarkTheme` sync, so a theme chosen on one machine
-- and the toggle's way back to it -- are the same everywhere. That is
already true, by the rule that DEVICE_KEYS is a list of exceptions and
anything else syncs by default, but nothing said so.

The existing test cannot say it: it derives what should sync from
DEVICE_KEYS, so moving one of these into that list would move the
expectation with it and still pass. These name the two keys outright.
2026-08-26 11:37:47 -07:00
jcoffey-dev 9689ac8aae Let the toggle come back to the theme you were on
The top-bar toggle went to light from anything dark, and back to plain
"dark" -- which quietly moved an ihasmail user onto a theme they had
never chosen, two clicks and no way to tell what had happened. It did
the same to "match system", which the toggle could not restore at all;
the comment above it conceded as much and sent people to Settings.

There is more than one way to be dark now, so the way back is
remembered: lastDarkTheme holds whichever non-light theme was last
chosen, and the toggle returns to that.

The remembering lives in update(), the single path every way of setting
a theme goes through -- the toggle, Appearance, an imported settings
file -- so a fourth way to choose one cannot forget to record it. Light
never overwrites it, since light is the side being toggled away from.

The button's label follows: "Switch to the ihasmail theme", "Switch to
your system theme", rather than claiming everything dark is "dark mode".

Confirmed in a browser, not only in tests: from a fresh profile the
round trip ihasmail -> light -> ihasmail returns to ihasmail, and
system -> light -> system returns to system, with the label naming the
destination each time.
2026-08-26 11:34:45 -07:00
jcoffey-dev 8487f561f6 Add the ihasmail theme, and make it the default
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.
2026-08-26 11:28:47 -07:00
LINUXexpert.org 16351abdbf Merge pull request #63 from LINUXexpert-org/login-ihasmail-org
Rework the sign-in footer
2026-08-26 10:50:07 -07:00
LINUXexpert.org d90270b204 Merge pull request #62 from LINUXexpert-org/readme-live-verified
Nothing left pending in QA
2026-08-26 10:49:15 -07:00
jcoffey-dev 48805c8072 Split the sign-in footer over two lines
The build and where to find it were running together on one line. The
name and version now stand on their own, with the two links beneath:

    ihasmail v2.16.61
    ihasmail.org · AGPL-3.0 source

"by" is gone with the restructuring. It was doing attribution work that
the line no longer needs -- ihasmail.org is a LINUXexpert.org project
site, so pointing at it says the same thing without the preposition, and
the README badge is where the credit properly lives.

One <p> with a break rather than two paragraphs: .foot carries a 20px
margin-top, which a second one would repeat as a gap under the button.
2026-08-26 10:48:14 -07:00
jcoffey-dev ecc6c42bf1 Point the sign-in footer at ihasmail.org
The link went to linuxexpert.org, which is the organisation rather than
the project. Someone reading a sign-in page and following it wants the
software's own site, not its publisher's.

Checked that https://ihasmail.org answers 200 before putting it in front
of everyone who reaches the instance -- a dead link on a sign-in page is
worse than none.

The README badge still credits LINUXexpert.org, which is the right place
for attribution and was not part of this.
2026-08-26 10:43:11 -07:00
jcoffey-dev d0fbbbc44e Nothing left pending in QA
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.
2026-08-26 10:38:34 -07:00
LINUXexpert.org fd0fe43ef7 Merge pull request #61 from LINUXexpert-org/deploy-self-rewrite
Stop the deploy script rewriting itself mid-run
2026-08-26 10:29:56 -07:00
jcoffey-dev 82108ebd97 Stop the deploy script rewriting itself mid-run
Moving the deploy script into the repo put it inside the checkout it
resets, and bash does not read a script all at once -- it reads as it
goes, by byte offset. `git reset --hard` replacing the file underneath a
running shell makes it stop wherever it had reached.

Silently, and with exit status 0. A three-line demonstration:

    echo "line A"
    cat > "$0" <<'NEW'
    echo "REWRITTEN"
    NEW
    echo "line B"

prints "line A" and nothing else, and exits 0. In a deploy that means
building the image, then stopping before the container is replaced, and
reporting success -- so the old container keeps serving while everything
says the new one shipped.

It only bites when a deploy carries a change to the deploy script
itself, which is rare enough to be baffling when it happens and exactly
the sort of quiet failure this project keeps paying for.

The script now re-execs from a copy outside the tree before touching
git, so the file being run cannot change while it runs, and removes the
copy on exit. Running it from outside the checkout -- as the host did
before this moved into the repo -- skips all of that.

The trap is an `if` rather than `[ -n ... ] && trap`, which would leave
the not-re-exec'd path resting on errexit ignoring a failed left operand
of &&. It does ignore it, but a deploy script is a poor place to depend
on knowing that.

Verified: with the guard, a script that overwrites itself mid-run
completes every later line and cleans up its copy; without it, the lines
after the rewrite never run.
2026-08-26 10:26:58 -07:00
LINUXexpert.org ea83631609 Merge pull request #60 from LINUXexpert-org/deploy-example
Add a deploy script, with no host in it
2026-08-26 10:23:02 -07:00
LINUXexpert.org 0613b5bf29 Merge pull request #59 from LINUXexpert-org/version-numbers
Give builds a version number
2026-08-26 10:22:28 -07:00
jcoffey-dev 2d72e870c4 Add a deploy script, with no host in it
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.
2026-08-26 10:18:44 -07:00
jcoffey-dev 4618f7656e Show the version on the sign-in page too
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.
2026-08-26 10:16:59 -07:00
jcoffey-dev bf70ba9df0 Give builds a version number
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)".
2026-08-26 10:00:29 -07:00
LINUXexpert.org 2a741f6407 Merge pull request #57 from LINUXexpert-org/drop-stalwart-0.15
Drop Stalwart 0.15 support
2026-08-26 09:52:52 -07:00
jcoffey-dev 94bf42cfda Drop Stalwart 0.15 support
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.
2026-08-26 09:51:03 -07:00
LINUXexpert.org f41e3d2631 Merge pull request #58 from LINUXexpert-org/ci-workflow-dispatch
Let CI be started by hand
2026-08-26 09:47:28 -07:00
LINUXexpert.org d739ad625d Merge pull request #56 from LINUXexpert-org/readme-settings-sync-verified
Record the settings file as verified live
2026-08-26 09:46:44 -07:00
jcoffey-dev 8e02300000 Let CI be started by hand
CI triggers on a push to main and on pull requests, and on nothing else.
That left no way to put a check on 7a1e5ee -- the commit production is
running -- after GitHub's Actions outage on 2026-08-26 orphaned every run
created during it.

Those runs are not merely slow. GitHub accepted them, allocated zero
jobs, and left them in a state its own API contradicts itself about:

  gh run rerun   ->  "cannot be rerun; This workflow is already running"
  gh run cancel  ->  "Cannot cancel a workflow run that is completed"
  gh api         ->  status=queued, conclusion=null, jobs=0

Neither recoverable nor clearable, and with no manual trigger the only
remaining option would have been an empty commit pushed to main to move
the ref -- which is both a junk commit and against how changes land here.

workflow_dispatch also covers the ordinary case of wanting a check on a
commit that predates a CI change.
2026-08-26 09:45:33 -07:00
jcoffey-dev 326c122231 Record the settings file as verified live
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.
2026-08-26 08:51:22 -07:00
LINUXexpert.org 26a017f1c4 Merge pull request #55 from LINUXexpert-org/settings-server-side
Keep settings with the account, not the browser
2026-08-26 08:24:03 -07:00
jcoffey-dev 0a9218f622 Keep settings with the account, not the browser
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.
2026-08-26 08:19:57 -07:00
LINUXexpert.org f9f442072b Merge pull request #53 from LINUXexpert-org/screenshots-refresh
Retake the screenshots on the new logo
2026-08-25 15:53:04 -07:00
jcoffey-dev 5a87a101c3 Retake the screenshots on the new logo, and refresh the README header
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.
2026-08-25 15:47:50 -07:00
LINUXexpert.org 046f8b7e58 Merge pull request #52 from LINUXexpert-org/logo-drop-com-wordmark
Drop the .com wordmark from the logo
2026-08-25 15:34:58 -07:00
jcoffey-dev c81e4f1aa9 Drop the .com wordmark from the logo
The logo baked "ihasmail.com" into the artwork, which is the wrong
identity for a project that is not the hosted instance -- and at the
34px the topbar renders it at, the wordmark was an illegible smudge
under a squashed cat.

logo.png, icon-512.png, icon-192.png and apple-touch-icon.png are now
the mark alone. favicon-64.png, icon-maskable.png and favicon.ico
already were, and are untouched.

Cropping needed a threshold: both source files carry a band of
near-invisible pixels (alpha 1-10) roughly 40px wide down the left
side, so a plain getbbox() crop leaves the mark sitting off-centre.
The bounding box is taken at alpha > 8 instead.

The login page had no name of its own -- it relied on the wordmark --
so it gets one as text, styled like the topbar's. Its screenshot is
retaken; the rest still show the old mark in the topbar.
2026-08-25 15:32:17 -07:00
LINUXexpert.org bc6a605629 Update email format for reporting security issues 2026-08-25 14:31:45 -07:00
LINUXexpert.org 798f10e4aa Modify email address format in CONTRIBUTING.md
Updated email format for reporting security issues.
2026-08-25 14:31:03 -07:00
LINUXexpert.org e81214956b Update contact email format for reporting vulnerabilities 2026-08-25 14:29:28 -07:00
LINUXexpert.org 401814cf3b Add Contributor Covenant Code of Conduct
Added Contributor Covenant Code of Conduct to promote a respectful and inclusive community.
2026-08-25 14:27:34 -07:00
LINUXexpert.org 0c86208455 Revise SECURITY.md for clarity on support and reporting
Updated the security policy to clarify supported versions and reporting procedures for vulnerabilities.
2026-08-25 14:26:43 -07:00
LINUXexpert.org 3c8238d6fd Create CONTRIBUTING.md 2026-08-25 14:25:11 -07:00
LINUXexpert.org 558b1bcce2 Merge pull request #51 from LINUXexpert-org/readme-badges-trim
Trim the badges
2026-08-25 13:54:09 -07:00
jcoffey-dev 55fb3535d0 Trim the badges to three, and credit LINUXexpert.org
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.
2026-08-25 13:52:13 -07:00
LINUXexpert.org 7b274c7c7a Merge pull request #50 from LINUXexpert-org/readme-badges
Put badges under the logo
2026-08-25 13:48:24 -07:00
LINUXexpert.org d448a72d6c Merge pull request #49 from LINUXexpert-org/agpl-source-offer
Make the AGPL's source offer point at the source being run
2026-08-25 13:47:41 -07:00
jcoffey-dev a2f0852437 Put badges under the logo
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.
2026-08-25 13:46:32 -07:00
jcoffey-dev 7b05322577 Make the AGPL's source offer point at the source being run
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.
2026-08-25 13:42:11 -07:00
LINUXexpert.org 259b625c3e Merge pull request #44 from LINUXexpert-org/push-dot-and-menu-close
Close the colour menu on a pick, and make the push dot readable
2026-08-25 13:18:20 -07:00
jcoffey-dev 732fdac78b Close the colour menu on a pick, and make the push dot readable
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.
2026-08-25 13:15:56 -07:00
LINUXexpert.org 5499971c54 Merge pull request #42 from LINUXexpert-org/folder-colours-filled
Fill a coloured folder in, rather than outlining it
2026-08-25 13:07:23 -07:00
jcoffey-dev fe09961383 Fill a coloured folder in, rather than outlining it
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.
2026-08-25 13:04:45 -07:00
LINUXexpert.org 037843a79d Merge pull request #41 from LINUXexpert-org/folder-colours
Give a folder a colour from its right-click menu
2026-08-25 12:57:37 -07:00
jcoffey-dev bbd6980cff Give a folder a colour from its right-click menu
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.
2026-08-25 12:54:33 -07:00
LINUXexpert.org b78d452d29 Merge pull request #40 from LINUXexpert-org/folder-drag-to-move
Move a folder by dragging it
2026-08-25 12:39:09 -07:00
jcoffey-dev bc4bad8466 Move a folder by dragging it
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.
2026-08-25 12:36:09 -07:00
LINUXexpert.org 418d3fafce Merge pull request #39 from LINUXexpert-org/sieve-detach-only-fileinto
Take the filing action, not the whole rule
2026-08-25 12:25:41 -07:00
jcoffey-dev 7f382565e8 Take the filing action, not the whole rule
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.
2026-08-25 12:23:29 -07:00
LINUXexpert.org 1f14b818ef Merge pull request #38 from LINUXexpert-org/relicense-agpl3
Relicense to the AGPL, version 3 or later
2026-08-25 12:18:40 -07:00
LINUXexpert.org c2871304f1 Merge pull request #37 from LINUXexpert-org/sieve-follows-folders
Keep filter rules pointing at the folder they were aimed at
2026-08-25 12:16:22 -07:00
jcoffey-dev 8cbbc9cc6c Relicense to the AGPL, version 3 or later
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.
2026-08-25 12:13:41 -07:00
jcoffey-dev 9da1ef3ead Keep filter rules pointing at the folder they were aimed at
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.
2026-08-25 12:12:30 -07:00
LINUXexpert.org bb71a2d063 Merge pull request #36 from LINUXexpert-org/screenshot-script-guards
Make the light screenshot actually light
2026-08-25 10:41:49 -07:00
jcoffey-dev 89d2d0128e Make the light screenshot actually light
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.
2026-08-25 10:39:44 -07:00
LINUXexpert.org 608bac62be Merge pull request #35 from LINUXexpert-org/readme-screenshots-refresh
Take the screenshots again, on today's build
2026-08-25 10:19:25 -07:00
jcoffey-dev 35ce2a8d3d Take the screenshots again, on today's build
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.
2026-08-25 10:16:14 -07:00
LINUXexpert.org d6c88e809a Merge pull request #34 from LINUXexpert-org/scheduled-send-verified-live
Record that a hold really does hold, on the live server
2026-08-25 09:30:21 -07:00
jcoffey-dev a195589e51 Record that a hold really does hold, on the live server
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.
2026-08-25 09:22:58 -07:00
LINUXexpert.org 8551d5417d Merge pull request #33 from LINUXexpert-org/calendar-vocabulary-into-main
Bring the stranded calendar work into main
2026-08-25 09:08:18 -07:00
jcoffey-dev aeba426203 Merge remote-tracking branch 'origin/calendar-recurring-warning' into calendar-vocabulary-into-main 2026-08-25 09:05:48 -07:00
LINUXexpert.org 04c79f6c5d Merge pull request #32 from LINUXexpert-org/sieve-rule-drag-reorder
Let a filter rule be dragged into place
2026-08-25 08:53:39 -07:00
jcoffey-dev 696b3713ed Let a filter rule be dragged into place
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.
2026-08-25 08:51:28 -07:00
LINUXexpert.org 5a21763605 Merge pull request #31 from LINUXexpert-org/calendar-stalwart-vocabulary
Say it in the words Stalwart 0.16 answers to
2026-08-25 08:42:25 -07:00
jcoffey-dev b0525d10d4 Record that RSVP and the edit path hold up too
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.
2026-08-25 08:39:40 -07:00
jcoffey-dev 4558804752 Record that the invitation reached a real guest and came back
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.
2026-08-25 08:31:47 -07:00
jcoffey-dev c1ef19849e Say it in the words Stalwart 0.16 answers to
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 #26
Fixes #30
2026-08-25 08:26:34 -07:00
jcoffey-dev 458eb118b4 Ask the recurrenceId, which is the part that survives expansion
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.
2026-08-25 08:18:38 -07:00
jcoffey-dev 330cecfb04 Ask only the recurrence rules, the live server settles it
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.
2026-08-25 08:12:06 -07:00
LINUXexpert.org b4fd3d3ab4 Merge pull request #29 from LINUXexpert-org/sieve-custom-header-operator
Give a hand-typed header its own box
2026-08-25 07:58:37 -07:00
jcoffey-dev 0c334a113a Give a hand-typed header its own box
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
2026-08-25 07:56:01 -07:00
LINUXexpert.org 3a93de451c Merge pull request #28 from LINUXexpert-org/calendar-recurring-warning
Stop calling every event a series
2026-08-25 07:53:00 -07:00
LINUXexpert.org f5af5913cf Merge pull request #27 from LINUXexpert-org/sieve-rule-order-on-edit
Keep an edited filter rule where it was
2026-08-25 07:52:32 -07:00
jcoffey-dev 8b22ea9aab Stop calling every event a series
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
2026-08-25 07:01:23 -07:00
jcoffey-dev fbcdff68da Keep an edited filter rule where it was
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
2026-08-25 06:55:47 -07:00
LINUXexpert.org 0b922e175f Merge pull request #22 from LINUXexpert-org/registry-path-verified
Record that the registry path has now met a live 0.16.19
2026-08-24 23:06:14 -07:00
LINUXexpert.org 77d9804583 Merge branch 'main' into registry-path-verified 2026-08-24 23:04:33 -07:00
jcoffey-dev ccf9fa34f0 Record that the registry path has now met a live 0.16.19
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.
2026-08-24 23:02:58 -07:00
LINUXexpert.org 5393524dc7 Merge pull request #21 from LINUXexpert-org/read-receipts
Send the read receipt the sender asked for
2026-08-24 23:01:03 -07:00
jcoffey-dev 46abc1c083 Drop S/MIME and OpenPGP from the roadmap
Not worth the cost right now. Removing it rather than leaving it sitting
there unstarted, so the list says what is actually intended.
2026-08-24 22:59:15 -07:00
jcoffey-dev 3310149fcc Send the read receipt the sender asked for
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.
2026-08-24 22:53:06 -07:00
jcoffey-dev be75a02181 Merge scheduled send
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.
2026-08-24 22:24:13 -07:00
jcoffey-dev 14125a0799 Look for Stalwart's capability where Stalwart advertises it
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.
2026-08-24 22:21:26 -07:00
jcoffey-dev e720623895 Hold a message in the server's queue until the time you asked for
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.
2026-08-24 22:07:29 -07:00
jcoffey-dev 03b5a6c388 Say which Stalwart the live instance runs, and credit the tool that moved it
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.
2026-08-24 17:38:39 -07:00
LINUXexpert.org 10331e14d9 Merge pull request #20 from LINUXexpert-org/empty-trash-batching
Empty a folder in batches the server will accept
2026-08-24 12:57:24 -07:00
jcoffey-dev 7095968282 Empty a folder in batches the server will accept
Emptying Deleted Items back-referenced one Email/query straight into one
Email/set, so every id in the folder arrived in a single call. Stalwart
refuses the whole call over maxObjectsInSet with requestTooLarge, which
left a folder of 5192 messages impossible to empty at all.

Walk the folder a page at a time instead: the filter re-runs each pass,
so the next page is whatever is still there. A pass that destroys nothing
stops the loop and reports the server's error rather than spinning.

The same defect sat in two neighbours. Delete-forever on a large
selection sent every id in one Email/set, and mark-all-read fed up to
5000 ids into an Email/get that only echoed them back - past
maxObjectsInGet, and for no gain, since the query already returned them.
Both now page through the same ceiling, read from the session rather than
hardcoded.

The mock advertised maxObjectsInSet but never enforced it, so none of
this could fail in a test. It now rejects oversized get and set calls the
way Stalwart does.

While here, restrict emptying to Deleted Items. It was offered on Junk
too, where a permanent one-shot clear is harder to justify; Junk is now
select-all plus Delete, which takes the batched path.
2026-08-24 12:55:23 -07:00
LINUXexpert.org 0cb7404330 Merge pull request #19 from LINUXexpert-org/image-proxy-pinning
Connect the image proxy to the address it checked
2026-08-24 11:52:45 -07:00
jcoffey-dev 9d06dce473 Connect the image proxy to the address it checked
The proxy resolved a hostname, refused it if any answer pointed somewhere
private, and then handed the *hostname* to fetch — which resolved it again when
the socket opened. An attacker who controls the zone answers with a public
address the first time and 127.0.0.1 the second, and the check has been walked
straight past. It is the standard way an SSRF guard gets bypassed.

Resolve once and connect to that address: a `lookup` that returns what we
already approved, on `node:http`/`node:https` rather than fetch, since fetch
gives no say in how the socket is opened. Every redirect hop is re-checked and
re-pinned. TLS is unaffected — the certificate is still validated against the
hostname, which `servername` and the Host header carry.

Pooling had to go with it: sockets are keyed by host and port, not by the
address we pinned, so a connection opened earlier would be reused and the pin
never consulted. Found by a test, not by reading it back.

Also refuse two ranges the old check let through: IPv6 multicast, and the NAT64
prefix, which is a route into IPv4 space.
2026-08-24 11:27:14 -07:00
LINUXexpert.org e21944a031 Merge pull request #18 from LINUXexpert-org/audit-hardening
Harden four things the security audit turned up
2026-08-24 11:12:43 -07:00
jcoffey-dev c5f2e2c7f2 Harden four things the audit turned up
**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.
2026-08-24 11:10:15 -07:00
LINUXexpert.org 984ebc185d Merge pull request #17 from LINUXexpert-org/message-css-containment
Stop a message painting over the whole application
2026-08-24 11:07:54 -07:00
jcoffey-dev 6242d4dee1 Stop a message painting over the whole application
A shadow root scopes selectors, not layout. `position:fixed` in mail CSS is
still positioned against the viewport, and the containment meant to stop that
sat inside the shadow root as `.ihm-email-root { contain: content }` — in the
same tree as the message's own <style>, which is inserted after it and simply
overrides it. Any sender could cover the entire window with markup of their
choosing, inside our own origin: a ready-made place to ask for a password.

Verified in a browser: the message rendered at exactly the viewport size with
the maximum z-index.

Moving the containment onto the shadow host does not fix it — mail CSS reaches
the host through `:host`, and an `!important` there beats an `!important` from
the app's own stylesheet, because importance reverses tree order in the
cascade. An ancestor of the host is the one thing mail CSS has no selector
for, so the control goes on .message-body. `layout` rather than `paint`: it
makes the element a containing block for fixed descendants without clipping
tall messages.

The sanitizer now also turns fixed and sticky positioning static and defangs
`:host`, as a second line that does not depend on one CSS declaration.

Checked end to end against the real stylesheet afterwards: the same message
renders 1678x112 instead of 1720x1279.
2026-08-24 11:00:10 -07:00
LINUXexpert.org f29a504ead Merge pull request #16 from LINUXexpert-org/legacy-mock
Teach the mock to impersonate Stalwart 0.15
2026-08-24 10:27:04 -07:00
jcoffey-dev 0845c93c4c Teach the mock to impersonate Stalwart 0.15
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.
2026-08-24 10:23:39 -07:00
LINUXexpert.org 88885f316a Merge pull request #15 from LINUXexpert-org/signature-byte-limit
Make signatures and Files work on Stalwart before 0.16
2026-08-24 10:13:30 -07:00
jcoffey-dev dab4808baa Record the live Files and signature verification
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.
2026-08-24 10:11:08 -07:00
jcoffey-dev 8d90bca6b4 List Files through get, because query cannot see a folder before 0.16
Creating a folder on the live 0.15.5 server did nothing visible, with no error
and nothing after a reload. The folder was real all along: FileNode/query masks
its results with document_ids(false) — resources that are *not* containers — so
it returns files and never folders, and says nothing about the omission.

FileNode/get carries no such mask, so on those servers the whole tree comes
from a single get with ids:null instead. That also stops ensureFolder making a
fresh "ihasmail" folder on every signature save, having never been able to find
the one already there.
2026-08-24 10:05:08 -07:00
jcoffey-dev 5befef7ed7 Translate the older FileNode rights, so Rename and Delete work again
Deleting a file did nothing on the live 0.15.5 server, with no error: the menu
items are gated on myRights.mayDelete and myRights.mayRename, and 0.16 was the
release that split rights up. Before it a node carried mayRead, mayWrite and
mayShare, with the one mayWrite covering everything the newer release names
separately — so both items sat permanently disabled.

Widen mayWrite into the four rights the newer shape names, alongside the
nodeType normalisation, and the UI can keep reading the 0.16 vocabulary.
2026-08-24 09:56:53 -07:00
jcoffey-dev 49ea07eeaa Stop sending nodeType to servers that have no such property
Uploading a file or creating a folder failed on the live 0.15.5 server with
`invalidProperties (nodeType)`. The property arrived in Stalwart 0.16; before
that a FileNode has no nodeType at all, and the create is refused outright.

Older servers tell a file from a directory a different way: the node carries
file properties or it does not. Setting blobId, size or type — even to null —
makes it a file, so a directory there is exactly parentId plus name.

0.16 is also the first release to advertise urn:stalwart:jmap and no earlier
one knows that capability, so its presence stands in for "has the newer
FileNode shape". Creates, and the property lists we ask for, are shaped from
that.

The read side needed it too: a server that never reports nodeType would have
had every folder drawn with a file icon, sorted among the files and opening as
a download. Nodes are normalised as they arrive, so everything downstream can
still just read nodeType.
2026-08-24 09:42:25 -07:00
jcoffey-dev 0057fce558 Measure signatures in bytes, so the oversize fallback actually saves
Stalwart accepts a signature of `value.len() < 2048`, and that is Rust's len():
2047 bytes of UTF-8. Every check here counted JavaScript `.length` instead,
which is UTF-16 units and agrees only for ASCII — an accent is one unit and two
bytes, CJK three, an emoji two units and four.

That alone would let a non-Latin signature we judged to fit come back rejected.
But the fallback that is supposed to rescue an oversize signature was broken
outright, for everyone: it truncated to `budget - 1` characters and appended an
ellipsis, one character but three bytes, so the result was always 2047
characters and 2049 bytes. Every marker signature Stalwart was ever offered was
two bytes too long, ASCII included. That is why this flow has been sitting in
the README as implemented but unconfirmed — the first person to exceed 2 KB
would have hit it.

Cutting the source text and rendering afterwards, rather than slicing the
rendered string, also means a cut can no longer land inside an HTML entity, and
stepping through code points means it cannot split a surrogate pair.

The old tests used ASCII only, which is how this survived; the new ones weigh
the encoded form.
2026-08-24 09:32:39 -07:00
LINUXexpert.org 682b5d77ee Merge pull request #14 from LINUXexpert-org/self-service-credentials
Self-service credentials, plus the account-locale fix, server info and a theme toggle
2026-08-24 09:13:55 -07:00
jcoffey-dev f5d26a1f61 Finish the README pass: stale locale method, and the two new bits of UI
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.
2026-08-24 09:12:19 -07:00
jcoffey-dev 25dfe714cb Record what the live 0.15.5 run actually verified
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.
2026-08-24 09:08:43 -07:00
jcoffey-dev 0fdcbbc778 Fix two things live testing on 0.15.5 turned up
**About said "not detected".** Generation was only worked out from the reply to
a registry method, which we never send to a server that does not advertise
urn:stalwart:jmap — every 0.16 build does, and nothing older knows the
capability at all, so its absence is already the answer. Say so, instead of
shrugging. A session with no capabilities at all stays unknown, which is a
different thing from old.

**The caret jumped out of the OTP field after one digit.** Dialog's autofocus
effect listed onClose in its dependencies, and every caller passes an inline
arrow, so each keystroke in a dialog holding state tore the effect down, set it
up again, and refocused the first field — which in the disable-2FA dialog is
the password. Keep the handler in a ref so the effect depends only on `open`.
This was a bug in the shared dialog rather than in one screen; every dialog
with more than one field had it.

The test for it fails against the old dependency array, not just passes
against the new one.
2026-08-24 09:04:16 -07:00
jcoffey-dev c8fe822586 Treat an unreadable backend probe as the older server, not an error
Stalwart before 0.16 does not know urn:stalwart:jmap, and rejects the whole
request rather than the one call when `using` names a capability it cannot
parse. The probe is only sent when the session advertises that capability, so
this should not arise — but if it ever does, throwing turns a server we can
still manage credentials on into a Security page that only shows an error.
Fall through to the endpoint those servers do have.
2026-08-24 08:41:15 -07:00
jcoffey-dev c145858bbe Read the locale where users can actually read it, and say which Stalwart answered
The account locale came from `x:Account/get`, which needs `sysAccountGet` — a
permission the built-in `user` role is not given, so the setting silently fell
back to the browser locale for exactly the people most likely to have set it.
Stalwart 0.16 carries the same field on `x:AccountSettings`, whose
`sysAccountSettingsGet` *is* part of that role. Both are now asked for in one
request and whichever answers wins, so admins and older servers keep working.

That pair of replies also says which generation we are talking to: only 0.16+
can parse the method name at all. About now reports that, plus the edition
from /api/account where the server offers it. It does not report a version
number because Stalwart does not publish one to clients — it hardcodes a
public "1.0.0" and keeps the real version to its SMTP internals — so the
screen says what was actually detected rather than inventing precision.

Also adds a light/dark toggle to the top bar, left of the settings button. The
stored setting is three-way, so the button acts on the theme actually on
screen: whichever one you see, a click gives you the other. Choosing "match
system" again stays in Settings › Appearance, where a three-way choice belongs.
2026-08-24 08:35:22 -07:00
jcoffey-dev 0f1fbcff93 Manage your own password, app passwords and 2FA
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.
2026-08-24 08:18:47 -07:00
LINUXexpert.org cea7545481 Merge pull request #13 from LINUXexpert-org/identity-submission-capability
Ask for the submission capability when using identities
2026-08-24 06:37:11 -07:00
jcoffey-dev a3fd236c36 Ask for the submission capability when using identities
Identity is defined by RFC 8621 under urn:ietf:params:jmap:submission, not
under mail. ihasmail asked for mail alone, so Stalwart 0.16 rejected both
Identity/get and Identity/set with unknownMethod: no identities were ever
listed, none could be created, and sending then failed with "No sending
identity available". Older Stalwart builds accepted the calls anyway, which
is why this went unnoticed.

Also filter `using` down to the capabilities the session actually advertises.
A server must reject the entire request with unknownCapability when `using`
names something it does not implement, so one over-eager urn would take down
every call sharing the batch — including, on a server predating the submission
capability, the mailbox and message loads batched alongside an identity fetch.

Fixes #12
2026-08-24 06:31:37 -07:00
LINUXexpert.org cfe5595882 Fix version number in known issues section
Updated known issues section with corrected version number and additional details.
2026-08-23 21:37:47 -07:00
LINUXexpert.org f63b28d54b Merge pull request #11 from LINUXexpert-org/contact-from-message
Add contacts by right-clicking anyone named in a message
2026-08-23 15:19:19 -07:00
jcoffey-dev d143e711d4 Add contacts by right-clicking anyone named in a message
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.
2026-08-23 14:46:38 -07:00
LINUXexpert.org 40f0ad5fdb Merge pull request #10 from LINUXexpert-org/fix-subject-focus
Stop the composer stealing focus while the subject is typed
2026-08-23 14:17:24 -07:00
jcoffey-dev 8faf9002c2 Stop the composer stealing focus while the subject is typed
The body editor was told to focus itself with

    autoFocus={d.to.length > 0 && Boolean(d.subject)}

and RichEditor ran that as an effect keyed on the prop. Typing the first
letter of a subject flipped Boolean(d.subject) false -> true, the effect fired,
and the caret jumped from the subject line into the message body.

autoFocus now means what it means on a DOM element: focus on mount. RichEditor
captures the prop in a ref and focuses once, and the composer decides where the
caret starts when it opens - recipients for a blank message, body for a reply
that already has recipients and a subject - instead of deriving it from state
that changes as the user types.

initialFocusTarget is extracted and exported so the rule is stated in one place
and tested. The regression test renders RichEditor and asserts it does not take
focus from a field being typed into; it fails against the previous effect.
2026-08-23 14:15:28 -07:00
LINUXexpert.org b4d89e94dc Merge pull request #9 from LINUXexpert-org/set-error-detail
Fix sending: never send null for an empty header property
2026-08-23 14:02:32 -07:00
jcoffey-dev 5575d540b8 Fix sending: never send null for an empty header property
Every message ihasmail sent set cc, bcc and replyTo to null when unused, and
inReplyTo/references likewise on a new message. Stalwart parses those
properties with try_into_address_list, which returns None for null, and the
create is rejected outright:

    if let Some(addresses) = value.try_into_address_list() { ... }
    else { response.invalid_property_create(id, header); continue 'create; }

So every send failed with "Invalid property or value.", new messages and
replies alike, regardless of attachments or signature. The mock server
accepts anything, which is why this only showed up against a real server.

Empty header properties are now omitted. On a create there is no previous
value to clear, so null was never needed - only the properties actually being
set belong in the object.

buildEmailObject is exported so the shape can be tested directly, with a
regression test that no property is ever null.
2026-08-23 13:59:33 -07:00
LINUXexpert.org ef1b52030b Merge pull request #8 from LINUXexpert-org/remove-live-hostname
Keep the live mail host out of the repo
2026-08-23 13:57:10 -07:00
jcoffey-dev f09566b56e Keep the live mail host out of the repo
Replace the hard-coded mail.inbuxa.com with generic placeholders: the config
default and .env.example use mail.example.com, the README stops naming the QA
host, and docker-compose now requires STALWART_URL to be set rather than
defaulting to somebody's real server.

Nothing deployed depends on the old default - the running container passes
STALWART_URL explicitly.
2026-08-23 13:55:17 -07:00
jcoffey-dev e05880eefc Say which property a JMAP SetError rejected
"Send failed: Invalid property or value." is Stalwart's description for
invalidProperties, and on its own it says nothing about what to fix. The
SetError also carries a `properties` array naming the offending fields, which
every call site was discarding.

setErrorMessage appends them, and the 35 places that surfaced a SetError -
send, save draft, mailboxes, calendars, contacts, sieve, files, signature
images, sharing - now go through it.
2026-08-23 13:51:02 -07:00
LINUXexpert.org 55af4eab8f Merge pull request #7 from LINUXexpert-org/message-theme-option
Let messages follow the app theme, at the user's choice
2026-08-23 13:36:38 -07:00
jcoffey-dev c3cecf9916 Let messages follow the app theme, at the user's choice
Messages render on a white card in every theme. That is deliberate for mail
that styles itself, but #4 points out the case it gets wrong: a message with
no styling of its own has nothing worth preserving, and flashing white at
someone reading in the dark is a real cost.

Appearance gains a switch under the theme cards, off by default so the
current behaviour is unchanged. With it on, HTML mail that declares no
colours follows the app theme; mail that sets a background or text colour
still gets the light card it was designed for, because half-darkening someone
else's design is worse than leaving it alone. Plain-text mail already
followed the theme and is untouched by the switch.

The themed palette is expressed in the app's own custom properties, which
cross the shadow boundary, so switching theme repaints open messages without
re-rendering them, and the accent-coloured link stays consistent. The host
element takes color-scheme: inherit so form controls and scrollbars inside a
message match too.

htmlDeclaresColors covers bgcolor attributes, <font color>, and colour or
background declarations in style attributes and <style> blocks, while
ignoring near-misses like border-color and ?color= in a URL.

Closes #4
2026-08-23 13:34:24 -07:00
LINUXexpert.org a89fc2b26e Merge pull request #6 from LINUXexpert-org/default-mail-handler
Offer ihasmail as the browser's mailto: handler
2026-08-23 13:29:21 -07:00
LINUXexpert.org 462721ce7f Merge pull request #5 from LINUXexpert-org/custom-date-time-pickers
Custom date and time pickers that follow the configured format
2026-08-23 13:27:02 -07:00
jcoffey-dev 2c23ea980b Offer ihasmail as the browser's mailto: handler
Settings > General gains a "Default mail app" section that calls
registerProtocolHandler so mail links anywhere in the browser open ihasmail.
The browser owns the decision and there is no API to read it back, so the UI
says what it can: it records that we asked, offers "Ask again", shows a
Remove button where unregisterProtocolHandler exists, and points at the
browser's own settings. Unsupported browsers (Safari) and insecure contexts
get an explanation instead of a dead button.

The manifest now declares protocol_handlers for mailto, which is the route by
which an *installed* app can be offered by the operating system itself; the
UI says so and links the two ideas rather than promising a system-wide
default the page cannot grant.

Mailto parsing is now one function (parseMailto in lib/address.ts) instead of
three hand-rolled copies in AppShell and MessageView. It follows RFC 6068:
recipients from the path, the to= header or both, case-insensitive headers,
"+" as space, and tolerant of malformed escapes. That fixes Cc and Bcc being
silently dropped, and draftFromMailto escapes the body so a mailto: URL from
an untrusted page reaches the composer as text rather than markup.
2026-08-23 13:21:50 -07:00
jcoffey-dev 2518605126 Custom date and time pickers that follow the configured format
Browsers render <input type="date"> and datetime-local in their own locale and
ignore the page's, so #1 left a German user on an English browser reading
22.11.2025 everywhere but still entering dates through an mm/dd/yyyy widget.
#3 makes the case that people use the picker rather than typing, which is
where the AM/PM mistakes happen.

New DateField and DateTimeField (web/src/ui/datefield.tsx) replace all nine
native controls — event editor (all-day and timed start/end, recurrence
until), out-of-office, contact birthday, advanced search. They take and emit
the same ISO strings the native inputs did, so call sites barely changed.

Each is a text box in the configured order plus a popover: a month grid
(week start from settings, locale weekday and month names, today and the
selection marked) and, for date-times, a list of times in the configured
clock. Keyboard: arrows move by day, PageUp/PageDown by month, Home/End
across the week, Enter picks, Escape closes, ArrowDown opens; the focused day
holds DOM focus so screen readers follow, and the dialog has an accessible
name (Popover gained an ariaLabel prop).

Text entry is lenient — the configured order with any separator, unseparated
digits (221125), day and month alone, non-Latin digits, and bare ISO always;
times take 18:23, 1823, 6:23pm, 930. What will not parse reverts on blur
rather than clearing the field, and impossible dates like 31 February are
rejected instead of rolling into March.

Editable boxes stay Gregorian and Latin-digit even where display does not
(fa-IR, th-TH, ar-EG): the locale's field order and separator are kept, but a
Buddhist-era year in a text box cannot round-trip against a Gregorian grid.
Noted in the README.

The out-of-office format echo added in #2 is gone — the fields now show the
right format themselves.

Closes #3
2026-08-23 13:12:17 -07:00
LINUXexpert.org c8fab0dd81 Merge pull request #2 from LINUXexpert-org/locale-date-time-formats
Configurable date and time formats, defaulting to the Stalwart locale
2026-08-23 12:43:36 -07:00
jcoffey-dev d82ff15921 Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by
three settings (Settings > General > Locale):

- Language & region: automatic, or any of the 618 locales CLDR has data for,
  each named in its own language and script (web/src/lib/locales.ts, generated
  by probing Intl over the subtag space).
- Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025,
  or ISO 8601 2025-11-22.
- Time format: automatic (locale), 24-hour, or 12-hour.

Automatic takes the locale Stalwart has for the account, read best-effort at
login via x:Account/get (urn:stalwart:jmap) and passed to the client in the
session; servers without the capability, or that deny sysAccountGet to a
regular user, fall back to the browser locale. POSIX forms are normalised
(de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS,
uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are
dropped and a script the locale already implies is not appended.

Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under
ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so
one line never mixes digit systems.

Rewired: message list and headers, quoted reply headers, calendar (titles,
weekday and hour gutters, mini calendar, agenda, popovers, invite cards,
free/busy), contacts, files, sessions. No raw toLocale*String date calls are
left in web/src.

Native <input type="datetime-local"> pickers always follow the browser locale
and cannot be restyled by a page, so the out-of-office fields echo the entered
instant in the chosen format underneath.

Also: month-grid day labels no longer wrap when they hold a date, and the mock
server serves x:Account/get (MOCK_LOCALE, default en_US).

Closes #1
2026-08-23 12:32:11 -07:00
LINUXexpert.org 00a57eabec Update README with new server version 2026-08-23 02:12:22 -07:00
jcoffey-dev de1b33e2aa Folder pane: align icons, mark-read incl. subfolders
- Move the expand chevron into a gutter left of the folder icon so folders
  with and without subfolders line up on their icon; labels share the column.
- Add "Mark all as read, incl. subfolders" to the folder context menu, with
  the affected count and a per-folder fallback for servers without filter
  operators.
- Re-measure the virtualised message list when row height changes.
- Mock: seed unread mail in a subfolder.
2026-08-23 01:34:15 -07:00
jcoffey-dev d079e2ad88 Update copyright holder to LINUXexpert.org 2026-08-23 01:25:18 -07:00
jcoffey-dev c99375bea6 Use verbatim GPL-3.0 text in LICENSE
The LICENSE file only carried the short "how to apply" notice, not the
license itself, so it wasn't a valid GPLv3 distribution and license
detection tools couldn't identify it. Replace it with the canonical
674-line GNU GPL v3 text (sha256 8ceb4b9e...), and move the project
copyright line plus the "version 3 or any later version" grant into the
README, which now matches package.json's GPL-3.0-or-later.
2026-08-23 01:15:29 -07:00
jcoffey-dev 645b8b510f ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
2026-08-23 01:07:13 -07:00
518 changed files with 104659 additions and 1086 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
**/node_modules
**/dist
.git
.env
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
.env.*
!.env.example
server/data
+115
View File
@@ -0,0 +1,115 @@
# ---- ihasmail server configuration ----
# Base URL of your Stalwart server (scheme + host, no path). ihasmail discovers
# the JMAP session at <STALWART_URL>/.well-known/jmap.
STALWART_URL=https://mail.example.com
# Random secret used to derive encryption keys for persisted sessions.
# Generate with: openssl rand -base64 48
APP_SECRET=change-me
# Listen address
HOST=0.0.0.0
PORT=8080
# Serve the app from a subpath instead of the domain root, for a reverse proxy
# that maps https://example.com/mail/ here. Leave it unset for the root, which
# is what every deployment gets unless it asks otherwise. "/mail", "mail" and
# "/mail/" all mean the same thing.
#
# The prefix must reach ihasmail intact -- do not strip it in the proxy -- and
# it has to be set for the *build* as well as the run: the web bundle writes
# its own asset URLs, so a build that does not know the prefix produces an app
# that cannot load itself under one. With Docker that means
# `--build-arg BASE_PATH=/mail` alongside `-e BASE_PATH=/mail`.
# BASE_PATH=/mail
# Set to "1" when running behind a TLS-terminating reverse proxy (trusts
# X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev.
TRUST_PROXY=1
# Peers whose X-Forwarded-* headers are believed. Unset means loopback and the
# private ranges, which covers a reverse proxy on the same host or Docker
# network. A request from anywhere else is attributed to its socket address,
# whatever the headers claim -- otherwise anyone could pick their own key for
# the login rate limiter.
# TRUSTED_PROXIES=10.0.0.0/8,192.168.1.5
SECURE_COOKIES=auto
# Session lifetime (idle timeout) in seconds. "Remember me" extends to SESSION_REMEMBER_TTL.
SESSION_TTL=43200
SESSION_REMEMBER_TTL=2592000
# Where to persist sessions so restarts don't log everyone out (optional).
# Leave it empty to hold sessions in memory only, which is what an immutable
# instance does -- see IMMUTABLE below.
SESSION_FILE=./data/sessions.json
# Assert that this instance is running as an immutable container: read-only
# root filesystem, no durable state of its own. It is checked rather than
# taken on trust -- the server refuses to start if SESSION_FILE is set, or if
# the filesystem it is installed on turns out to be writable. Off by default.
# Running one looks like:
# docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
# The cost today is that a restart signs everyone out, since there is nowhere
# left to keep the sessions. Removing that cost is what the OAuth work is for.
# IMMUTABLE=1
# Upstream timeouts / limits
UPSTREAM_TIMEOUT=30000
MAX_UPLOAD_BYTES=52428800
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
IMAGE_PROXY=1
# In-app administration, for accounts whose Stalwart role manages accounts and
# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses
# Stalwart's registry methods beyond an account's own password, app passwords
# and settings. Stalwart's own admin interface is not affected.
ADMINISTRATION=1
# Branding
APP_NAME=ihasmail
# Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which
# asks whoever runs a modified version to offer *that* version's source -- so if
# you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About.
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
# ---- Settings this installation decides (all optional) ----
#
# Seed what a new account starts on, lock what nobody may change, and turn
# something on once for accounts that already exist. Setting none of these --
# the default -- behaves exactly as ihasmail always has.
#
# A file is easier once there are `changes` in it. See the shipped
# settings-policy.example.json, and mount it read-only:
#
# -v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro
#
# SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json
#
# Or inline, which is what an immutable deployment with no volume wants. These
# are ignored entirely when SETTINGS_POLICY_FILE is set, so a file and a stray
# variable cannot half-apply between them.
#
# SETTINGS_DEFAULTS={"externalSenderBanner":true}
# SETTINGS_ENFORCED={"externalRecipientConfirm":true}
# SETTINGS_CHANGES=[{"version":"20260902084513","settings":{"externalSenderBanner":true}}]
#
# Read once at startup: editing a policy means restarting the container.
# Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides
# ---- Several Stalwart servers (optional) ----
#
# Choose the upstream by the domain someone signs in with. STALWART_URL above
# stays required and stays the default; this only adds domains that go
# elsewhere. See the shipped stalwart-servers.example.json, and mount it
# read-only:
#
# -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
#
# STALWART_SERVERS_FILE=/etc/ihasmail/servers.json
#
# An unlisted domain, or a username with no domain, goes to STALWART_URL. A
# listed domain never falls back. Read once at startup: editing means a restart.
+4
View File
@@ -0,0 +1,4 @@
# Funding platforms shown behind the repository's Sponsor button.
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: jcoffey-dev
+43
View File
@@ -0,0 +1,43 @@
<!--
Thanks for contributing to ihasmail. CONTRIBUTING.md has the full guide;
this is the short version. Delete any section that does not apply.
-->
## Summary
<!-- What changes, and why. -->
## Related issues
<!-- e.g. Closes #123. Leave blank if there are none. -->
## Translations
<!--
Nine languages ship alongside English, and a missing key silently renders
its English source -- so an untranslated string is invisible until somebody
reading that language finds it. Say which this PR is, explicitly:
- Adds or alters user-visible strings: how many keys, and the fallback
count before and after.
- Adds none.
"Adds none" is an answer. Saying nothing is not -- it leaves it to be
inferred. See CONTRIBUTING.md -> Translations.
-->
## Testing
<!--
What you ran, and what you saw. `npm run typecheck`, `npm test` and
`npm run build` all run in CI, so the useful thing here is what CI cannot
do: which flows you exercised by hand, and against what -- a real Stalwart
instance, or `npm run dev:mock`.
If the change is visible on screen, drive the built app, not just the
store. See CONTRIBUTING.md -> Verifying UI work.
-->
## Screenshots
<!-- For UI changes. Before/after, or a GIF for anything with motion. -->
+44
View File
@@ -0,0 +1,44 @@
version: 2
updates:
# The npm entry sits at the root because that is where the single lockfile
# is: root, server and web are one npm workspace, so one entry covers all
# three. Pointing entries at server/ or web/ would find package.json files
# with no lockfile beside them and update nothing.
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
open-pull-requests-limit: 5
groups:
# Everything routine arrives as one PR a week, so the dashboard is not
# the only place these get noticed. Majors are deliberately left out of
# the group: they are migrations, not bumps -- vitest 3 to 4 is one --
# and each deserves its own PR and its own CI run.
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
groups:
actions:
patterns:
- "*"
# The runtime and build stages both pin node:22-alpine, so this is what
# keeps the published container images off a stale base between the weekly
# releases.
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
+39
View File
@@ -0,0 +1,39 @@
name: CI
on:
push:
branches: [main]
pull_request:
# Lets CI be run by hand against any ref, including a specific commit.
# Without this there is no way to re-run a check that never started: a run
# GitHub queues and then orphans -- as it did to every run created during the
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
# nor canceled ("already completed"), and the workflow has no other trigger
# to reach for. Useful too for putting a check on a commit that predates a CI
# change, without pushing an empty commit to move it.
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
# Every `uses:` in this repository is pinned to a full commit SHA, with
# the release it belongs to in the trailing comment, and the repository
# requires it -- an unpinned ref fails the run rather than quietly
# resolving. A tag is a mutable pointer: `@v7` is whatever the publisher
# last moved it to, so trusting one is trusting every future version of
# that action, including the one pushed by whoever compromises the
# account. Read the comment for the version; the SHA is what runs.
#
# Dependabot updates both halves together on its weekly github-actions
# run, so this costs nothing to keep current -- do not "simplify" a pin
# back to a tag.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
cache: npm
- run: npm ci --ignore-scripts
- run: npm run typecheck
- run: npm test
- run: npm run build
- name: Docker build
run: docker build -t ihasmail:ci .
+69
View File
@@ -0,0 +1,69 @@
# Prune old image versions from GHCR.
#
# Releases are kept forever -- they carry no assets and their generated notes
# are this project's only changelog, so deleting one destroys history that
# cannot be reconstructed for nothing saved. Images are the opposite: a
# multi-arch build a week, and the by-digest push in publish.yml leaves two
# untagged per-architecture manifests behind each time on top of the tagged
# index. Those accumulate and nobody wants fifty of them.
#
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
# `delete-only-untagged-versions` -- will happily delete the per-architecture
# manifests that a multi-arch tag points *at*, because they are untagged by
# design. Nothing appears to break: the tag still exists, and pulls simply
# start failing for one architecture. This action understands manifest lists
# and will not orphan a retained index, and `validate` re-checks every
# multi-arch manifest against the registry afterwards.
#
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
# exactly what would be deleted without rebuilding and re-pushing an image to
# find out.
name: Prune images
on:
workflow_call:
inputs:
dry_run:
type: boolean
default: false
workflow_dispatch:
inputs:
dry_run:
description: "List what would be deleted, delete nothing"
type: boolean
default: true
jobs:
prune:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
# The only third-party action here that is not published by GitHub or
# Docker, and the one with the most to lose: it is handed
# `packages: write` and its whole job is deletion, so a ref repointed at
# something else -- by a compromise or a mistake upstream -- is a bad
# day. It was pinned to a commit long before the rest of them were.
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
with:
owner: Coffey-Labs
package: ihasmail
token: ${{ secrets.GITHUB_TOKEN }}
# Ten weekly releases is roughly a quarter of history, which is more
# than enough to roll back to and far less than the year's worth that
# would otherwise pile up. Older *releases* stay either way; this
# only removes the images.
keep-n-tagged: 10
# Belt and braces on top of the action's own manifest awareness:
# `latest` is never a candidate for deletion under any counting.
exclude-tags: latest
delete-untagged: true
# Sweeps the wreckage of a half-failed run: an index whose platform
# images did not all land, and referrers whose parent is gone.
delete-partial-images: true
delete-orphaned-images: true
# Checks every remaining multi-architecture manifest still resolves
# in the registry. This is the step that would catch the footgun
# above rather than leaving a reader to discover it on `docker pull`.
validate: true
dry-run: ${{ inputs.dry_run }}
+202
View File
@@ -0,0 +1,202 @@
# Publish the container image to GHCR.
#
# The README and the docs site have told people to run
# `ghcr.io/coffey-labs/ihasmail:latest` for a long time, and nothing ever
# pushed it: `docker pull` answered `denied`, because the package did not
# exist. This is the workflow that makes those instructions true. It is also
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
# both install by pulling an image and neither builds from source.
#
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
# a public repository, and an anonymous `docker pull` will still answer
# `denied`. Nothing in a workflow can change that -- the visibility is set once
# by hand under the package's settings, and until it is, this looks like it
# worked while the docs stay just as wrong as before. Check with a logged-out
# pull, not with one from a machine that has credentials.
#
# Two architectures, each built on its own native runner rather than under
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
# instruction translation, which takes tens of minutes and occasionally runs
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
# the same work at native speed. The cost is the by-digest dance below: each
# runner pushes an untagged image, and a final job joins the two digests into
# one multi-arch tag.
name: Publish image
on:
release:
types: [published]
# Callable, so release.yml can build the release it just cut. This is not a
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
# `release` event -- GitHub refuses to let a token trigger another workflow,
# to stop a workflow looping on its own output. A scheduled job that cut a
# release and expected this file to notice would silently never publish. The
# alternatives are a personal access token kept as a secret, or calling the
# workflow directly. This is the one that needs no credential.
workflow_call:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
type: string
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
# orphans can be neither rerun nor canceled, and this workflow otherwise
# only fires on a release -- which is not something to cut twice because a
# runner died. `ref` also allows publishing an image for a tag that predates
# this workflow, which is how the first one gets built.
workflow_dispatch:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
default: main
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
env:
# Hardcoded rather than derived from github.repository: a registry path must
# be lowercase and the owner is spelled `Coffey-Labs`, so deriving it means
# remembering to lowercase it. This is the string the docs already name.
IMAGE: ghcr.io/coffey-labs/ihasmail
jobs:
# The version is worked out once and handed to both builds, so the two
# architectures cannot disagree about what they are. scripts/version.mjs
# reads the commit date and how the commit arrived, so it needs real history
# rather than a shallow clone.
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
docker_tag: ${{ steps.v.outputs.docker_tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: v
run: |
V="$(node scripts/version.mjs)"
echo "version=$V" >> "$GITHUB_OUTPUT"
# A Docker tag may not contain '+', so build metadata becomes '-'.
# The build is still *told* the real form, which is what About and
# /api/health report.
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "version $V -> tag ${V/+/-}"
build:
needs: version
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
# Attestations are off deliberately: they add manifests of their own
# to the index, and `imagetools create` below expects the two entries
# it pushed rather than four.
provenance: false
sbom: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Save the digest
run: |
mkdir -p /tmp/digests
# The prefix is stripped here and put back in the merge job, so the
# filename is the bare hash. Leaving it on produces
# `image@sha256:sha256:...` when the reference is rebuilt.
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# One artifact per platform; the merge job globs them back together.
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
retention-days: 1
if-no-files-found: error
# Joins the per-architecture digests into a single tagged manifest, so
# `docker pull ghcr.io/coffey-labs/ihasmail:<tag>` resolves on both.
publish:
needs: [version, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create the manifest
run: |
# Arrays rather than a string: the tags and the digest references
# have to reach docker as separate arguments, and building them by
# word-splitting an unquoted variable is the version of this that
# breaks the day a value contains a space.
tags=(-t "${IMAGE}:${{ needs.version.outputs.docker_tag }}")
# :latest follows real releases only. A prerelease that moved it
# would hand every `:latest` deployment an unfinished build, and a
# dispatch run has to ask for it on purpose.
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
tags+=(-t "${IMAGE}:latest")
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
tags+=(-t "${IMAGE}:latest")
fi
refs=()
for f in /tmp/digests/*; do
refs+=("${IMAGE}@sha256:$(basename "$f")")
done
echo "tags: ${tags[*]}"
echo "refs: ${refs[*]}"
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
- name: Show what landed
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.docker_tag }}"
# Runs only after a successful publish, because that is the only moment the
# package grows. See cleanup.yml for why this is not the obvious one-liner.
prune:
needs: publish
permissions:
packages: write
uses: ./.github/workflows/cleanup.yml
+163
View File
@@ -0,0 +1,163 @@
# Cut a release once a week, but only if there is something in it.
#
# Releases had drifted 184 commits behind main, which made `:latest` describe
# a build nobody was running -- the demo, prod and anyone building from source
# were all ahead of it. Publishing on release is the right trigger only if
# releases actually happen, so this is the part that makes that true without
# anyone having to remember.
#
# It does nothing on a quiet week. A release with no commits in it is worse
# than no release: it moves `:latest` to an identical build, spends a version
# number, and mails everybody watching the repository about nothing.
name: Weekly release
on:
schedule:
# Mondays, 09:17 UTC. GitHub runs scheduled jobs on a best-effort basis and
# can delay a run by a good while when the queue is busy, so do not read
# the exact minute as a promise. The odd minute is deliberate: the top of
# the hour is when most schedules fire, and at 09:00 the first scheduled
# run started almost six hours late and the second had not started at all
# four and a half hours in. Moving off the hour does not make GitHub keep
# time, but it stops competing for the busiest slot. A missed week can be
# cut by hand with workflow_dispatch; a late scheduled run that follows
# finds the tag already there and does nothing.
#
# Note also that GitHub disables scheduled workflows in a repository with
# no activity for 60 days -- not a concern while this one is being worked
# on weekly, but it is why a silent stop is worth checking for before
# assuming the file is broken.
- cron: "17 9 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Work out what would be released, then stop"
type: boolean
default: false
# One at a time. Two overlapping runs would race to create the same tag, and
# the loser fails noisily for a reason that has nothing to do with the code.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
tag: ${{ steps.decide.outputs.tag }}
title: ${{ steps.decide.outputs.title }}
sha: ${{ steps.decide.outputs.sha }}
previous: ${{ steps.decide.outputs.previous }}
count: ${{ steps.decide.outputs.count }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: decide
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# The newest published release, or empty on a repository that has
# never had one -- in which case everything counts as new. Drafts are
# excluded: an unpublished draft is not a release anybody has, so
# counting from it would hide commits that have never shipped.
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
# A tag named by a release is normally present after a full checkout,
# but a release can outlive its tag. Falling back to the whole
# history is the safe direction to be wrong in: it over-counts, which
# cuts a release that was due anyway, where under-counting would skip
# one that was.
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
count="$(git rev-list --count "${previous}..HEAD")"
else
count="$(git rev-list --count HEAD)"
fi
version="$(node scripts/version.mjs)"
# A Docker tag may not contain '+', and neither should the git tag,
# so the two always agree about what to call a build.
tag="v${version/+/-}"
title="v${version%%+*}"
sha="$(git rev-parse HEAD)"
should_release=true
reason=""
if [ "$count" -eq 0 ]; then
should_release=false
reason="no commits since ${previous}"
elif git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
# Same commit, different week: the version is derived from the
# commit, so nothing new means the tag already exists.
should_release=false
reason="tag ${tag} already exists"
fi
{
echo "should_release=$should_release"
echo "tag=$tag"
echo "title=$title"
echo "sha=$sha"
echo "previous=$previous"
echo "count=$count"
} >> "$GITHUB_OUTPUT"
# Written to the run summary so a skipped week reads as a decision
# rather than as a workflow that quietly did nothing.
{
echo "### Weekly release"
echo
if [ "$should_release" = "true" ]; then
echo "Releasing **${tag}** — ${count} commit(s) since ${previous:-the beginning}."
else
echo "Nothing to release: ${reason}."
fi
} >> "$GITHUB_STEP_SUMMARY"
cut:
needs: check
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
args=(--target "${{ needs.check.outputs.sha }}"
--title "${{ needs.check.outputs.title }}"
--generate-notes)
# Bound the notes to what is actually new. Without a start tag the
# generator reaches back to whatever it decides is previous, which on
# a repository with older tag shapes is not always the last release.
if [ -n "${{ needs.check.outputs.previous }}" ]; then
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
fi
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
# Called rather than left to the `release` trigger on purpose: see the note
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
# event, so without this the tag would exist and no image would follow it.
publish:
needs: [check, cut]
permissions:
contents: read
packages: write
uses: ./.github/workflows/publish.yml
with:
ref: ${{ needs.check.outputs.sha }}
tag_latest: true
+12
View File
@@ -0,0 +1,12 @@
node_modules/
dist/
.env
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
.env.*
!.env.example
*.log
.DS_Store
server/data/
.vite/
coverage/
+237
View File
@@ -0,0 +1,237 @@
# CI for the self-hosted GitLab that replaced GitHub Actions when the account
# was suspended on 2026-09-20. This is a port of .github/workflows/ci.yml and
# publish.yml, which are kept in the tree for reference and for the day the
# appeal succeeds.
#
# Every `image:` here is pinned to a digest, with the tag it belonged to in the
# trailing comment. That is the direct replacement for the SHA-pinned `uses:`
# in the Actions workflows: GitLab has no equivalent of an action allowlist, so
# the only thing standing between this pipeline and whatever the publisher
# pushes to a tag next is the digest. Read the comment for the version; the
# digest is what runs. Do not "simplify" one back to a bare tag.
#
# The runner is a group runner on Web_Host with the host docker socket bound
# in, reached over the internal container network rather than
# https://git.coffeylabs.org -- that name is Cloudflare-proxied on the Free
# plan, which caps request bodies at 100 MB and would break artifact uploads.
stages: [test, build, publish, release]
variables:
# Jobs talk to the registry directly on its DNS-only name, never through the
# proxy, for the same 100 MB reason.
IMAGE: $CI_REGISTRY_IMAGE
GIT_DEPTH: "0"
default:
interruptible: true
# ---------------------------------------------------------------- test ------
node:
stage: test
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
variables:
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
cache:
key:
files: [package-lock.json]
paths: [.npm/]
before_script:
# version.test.ts shells out to git to resolve a build version, and the
# slim image ships without it. The clone is done by the runner's helper
# image, so nothing else here needs git and its absence is easy to miss.
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git
# config.test.ts chmods a directory to 0555 and expects the write to be
# refused. Root ignores the permission bits, so as root that assertion can
# never hold. The tests run as the image's unprivileged `node` user for
# that reason; -p keeps the environment.
#
# imageproxy.test.ts needs IPv6 as well, which is not set here but on the
# runner: jobs run on the `ci-net` docker network, created with --ipv6.
# Without a non-loopback IPv6 address on the container, getaddrinfo's
# AI_ADDRCONFIG drops ::1 from the results entirely, localhost resolves to
# IPv4 only, and the test's control case connects to a port nothing is
# listening on. That is a runner property, so it cannot be fixed from this
# file -- if these tests ever fail again with ECONNREFUSED on 127.0.0.1,
# check that the runner still puts jobs on an IPv6-enabled network.
- chown -R node:node "$CI_PROJECT_DIR"
script:
- su node -p -c "npm ci --ignore-scripts"
- su node -p -c "npm run typecheck"
- su node -p -c "npm test"
- su node -p -c "npm run build"
artifacts:
paths: [dist/]
expire_in: 1 week
rules:
- if: $RELEASE_WEEKLY == "1"
when: never
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
# --------------------------------------------------------------- build ------
# Proves the Dockerfile still builds on every change, without pushing. The
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step.
#
# Not called `image`: that is a reserved keyword, and a job by that name is
# silently read as the global image: setting instead ("image name should be a
# string"). Same trap for `stages`, `cache`, `services` and `variables`.
docker-build:
stage: build
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
needs: [node]
script:
- docker build -t ihasmail:ci-$CI_COMMIT_SHORT_SHA .
- docker image rm ihasmail:ci-$CI_COMMIT_SHORT_SHA
rules:
- if: $RELEASE_WEEKLY == "1"
when: never
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ------------------------------------------------------------- publish ------
# Tag-driven, replacing the release -> publish workflow_call chain. GitHub
# needed that dance because a release cut with GITHUB_TOKEN raises no event;
# GitLab has no such rule, so a tag pipeline is enough.
#
# arm64 is built under QEMU on this amd64 host, not on a native runner as
# GitHub's free `ubuntu-24.04-arm` did. It is slow -- tens of minutes for the
# npm install and Vite build through instruction translation -- which is
# tolerable for a weekly tag and would not be for every push. That is why this
# job is tag-only. If arm64 ever starts timing out, the fix is an arm64 runner,
# not dropping the platform: TrueNAS and Unraid users pull it.
# The version the image is built with, computed the way publish.yml did it:
# scripts/version.mjs, which needs node and the full history. The build is
# *told* the real form (IHASMAIL_VERSION, what About and /api/health report);
# the Docker tag gets the same string with '+' turned into '-', because a tag
# may not contain '+'. The first port of this job left the build arg out, so
# a tag would have shipped an image reporting itself unversioned -- which is
# exactly what version.test.ts calls looking wrong.
version:
stage: build
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
variables:
GIT_DEPTH: "0"
before_script:
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git >/dev/null
# The build directory is reused between jobs, and the node job chowns it to
# the unprivileged `node` user so its tests can run. A later job running
# git as root then finds the checkout owned by somebody else, and git
# refuses with "detected dubious ownership" (exit 128). Whether it happens
# depends on which cached directory a job lands on, so it comes and goes.
- git config --global --add safe.directory "$CI_PROJECT_DIR"
script:
- V="$(node scripts/version.mjs)"
- echo "VERSION=$V" > version.env
- echo "DOCKER_TAG=${V/+/-}" >> version.env
- cat version.env
artifacts:
reports:
dotenv: version.env
rules:
- if: $CI_COMMIT_TAG
publish:
stage: publish
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
needs: [node, version]
variables:
DOCKER_BUILDKIT: "1"
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker run --privileged --rm tonistiigi/binfmt --install arm64
- docker buildx create --use --name ci-builder --driver docker-container || docker buildx use ci-builder
script:
- |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg IHASMAIL_VERSION="$VERSION" \
--provenance=false --sbom=false \
--tag "$IMAGE:$DOCKER_TAG" \
--tag "$IMAGE:latest" \
--push .
after_script:
- docker logout "$CI_REGISTRY" || true
rules:
- if: $CI_COMMIT_TAG
# ----------------------------------------------------------- weekly release --
# Port of .github/workflows/release.yml: cut a release once a week, but only
# when there is something in it. The decision is the workflow's, unchanged --
# count the commits on main since the newest published release, and skip the
# week if there are none or if the tag already exists (the version comes from
# the commit, so an unchanged commit is an existing tag).
#
# It runs from a pipeline schedule (Mondays 09:17 UTC, the same odd minute as
# before) that sets RELEASE_WEEKLY=1. GitLab keeps schedules on the project,
# not in this file, so the schedule and this job only work as a pair. Run it by
# hand with RELEASE_WEEKLY=1, adding DRY_RUN=1 to see the decision and stop.
#
# The release -- and with it the tag -- is created with RELEASE_TOKEN, a
# project access token (protected, masked), not CI_JOB_TOKEN. A tag pushed that
# way is an ordinary push, so it starts the tag pipeline, and the version and
# publish jobs above build the image from it. That replaces release.yml's
# direct call of publish.yml, which only existed because a tag created with
# GITHUB_TOKEN raises no event. The token expires; when it does this job fails
# at the API call, loudly, and a new one goes in the same variable.
weekly-release:
stage: release
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
# One at a time: two overlapping runs would race to create the same tag.
resource_group: weekly-release
variables:
GIT_DEPTH: "0"
before_script:
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git curl jq >/dev/null
# See the version job: same shared directory, same root, same refusal.
- git config --global --add safe.directory "$CI_PROJECT_DIR"
script:
- |
set -euo pipefail
# Internal address, as for everything else CI does: never through the proxy.
API="http://gitlab/api/v4/projects/${CI_PROJECT_ID}"
auth=(--header "PRIVATE-TOKEN: ${RELEASE_TOKEN}")
# The newest published release, or empty on a project that has never had
# one -- in which case everything counts as new.
previous="$(curl -fsS "${auth[@]}" "${API}/releases?order_by=released_at&sort=desc&per_page=1" | jq -r '.[0].tag_name // ""')"
# A release can outlive its tag. Falling back to the whole history
# over-counts, which cuts a release that was due anyway; under-counting
# would skip one that was.
# Tag lookups use show-ref, which matches an exact ref and nothing else.
# `rev-parse --verify refs/tags/<name>` does not: on the git in this image
# (2.39) a name ending in -g<hex> falls back to being read as
# git-describe output, resolves to that commit, and so "exists" whether
# or not the tag does. Every commit not merged through a pull request has
# a -g<hex> version, so that check reported every such week as already
# released. Newer git (and GitHub's runners) do not fall back, which is
# why release.yml never showed it.
if [ -n "$previous" ] && git show-ref --verify --quiet "refs/tags/${previous}"; then
count="$(git rev-list --count "${previous}..HEAD")"; range="${previous}..HEAD"
else
count="$(git rev-list --count HEAD)"; range="HEAD"
fi
version="$(node scripts/version.mjs)"
# A Docker tag may not contain '+', and neither should the git tag, so
# the two always agree about what to call a build.
tag="v${version/+/-}"
title="v${version%%+*}"
sha="$(git rev-parse HEAD)"
if [ "$count" -eq 0 ]; then
echo "Nothing to release: no commits since ${previous}."; exit 0
fi
if git show-ref --verify --quiet "refs/tags/${tag}"; then
echo "Nothing to release: tag ${tag} already exists."; exit 0
fi
echo "Releasing ${tag} -- ${count} commit(s) since ${previous:-the beginning}, at ${sha}."
if [ "${DRY_RUN:-0}" = "1" ]; then echo "DRY_RUN=1: stopping here."; exit 0; fi
# Notes bounded to what is new, from the first-parent history of main --
# one line per merge, which is what GitHub's generated notes listed.
notes="$(git log --first-parent --format='- %s' "$range")"
jq -n --arg tag "$tag" --arg ref "$sha" --arg name "$title" \
--arg desc "$(printf '%s commit(s) since %s.\n\n%s' "$count" "${previous:-the beginning}" "$notes")" \
'{tag_name:$tag, ref:$ref, name:$name, description:$desc}' > release.json
curl -fsS "${auth[@]}" --header "Content-Type: application/json" \
--data @release.json "${API}/releases" | jq -r '"created release " + .tag_name'
rules:
- if: $RELEASE_WEEKLY == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+150
View File
@@ -0,0 +1,150 @@
# Upstream palette values, fetched from source (all MIT)
Fetched 2026-09-02 from the projects' own repositories, not from any
reimplementation.
## Dracula — dracula/dracula-theme, MIT
README section is titled "Color Palette (OSS)" and contains BOTH variants,
so Alucard is open source and not PRO-only.
### Dracula (dark)
Background #282a36 · Current Line #44475a · Selection #44475a
Foreground #f8f8f2 · Comment #6272a4
Cyan #8be9fd · Green #50fa7b · Orange #ffb86c · Pink #ff79c6
Purple #bd93f9 · Red #ff5555 · Yellow #f1fa8c
### Alucard (light)
Background #fffbeb · Current Line #6c664b · Selection #cfcfde
Foreground #1f1f1f · Comment #6c664b
Cyan #036a96 · Green #14710a · Orange #a34d14 · Pink #a3144d
Purple #644ac9 · Red #cb3a2a · Yellow #846e15
## Gruvbox — morhetz/gruvbox, MIT
dark0_hard #1d2021 · dark0 #282828 · dark0_soft #32302f · dark1 #3c3836
dark2 #504945 · dark3 #665c54 · dark4 #7c6f64 · gray #928374
light0_hard #f9f5d7 · light0 #fbf1c7 · light0_soft #f2e5bc · light1 #ebdbb2
light2 #d5c4a1 · light3 #bdae93 · light4 #a89984
bright: red #fb4934 green #b8bb26 yellow #fabd2f blue #83a598 purple #d3869b aqua #8ec07c orange #fe8019
neutral: red #cc241d green #98971a yellow #d79921 blue #458588 purple #b16286 aqua #689d6a orange #d65d0e
faded: red #9d0006 green #79740e yellow #b57614 blue #076678 purple #8f3f71 aqua #427b58 orange #af3a03
## Rosé Pine — rose-pine/palette, MIT (palette.json)
### main (dark)
base #191724 surface #1f1d2e overlay #26233a muted #6e6a86 subtle #908caa text #e0def4
love #eb6f92 gold #f6c177 rose #ebbcba pine #31748f foam #9ccfd8 iris #c4a7e7
### dawn (light)
base #faf4ed surface #fffaf3 overlay #f2e9e1 muted #9893a5 subtle #797593 text #464261
love #b4637a gold #ea9d34 rose #d7827e pine #286983 foam #56949f iris #907aa9
## Tokyo Night — enkia/tokyo-night-vscode-theme, MIT
### Night (dark)
bg #1a1b26 · bg_dark #16161e · fg #a9b1d6 · line numbers #363b54 · border #101014
selection #202330 · link #6183bb
accents: purple #bb9af7 · text-bright #c0caf5 · red #f7768e · cyan #0db9d7
blue #7aa2f7 · light-cyan #7dcfff · yellow #e0af68 · teal #73daca · green #9ece6a
### Day (light)
bg #e6e7ed · bg_dark #d6d8df · fg #343b59 · line numbers #9da0ab · border #c1c2c7
link #2959aa
accents: purple #65359d · red #8c4351 · cyan #006c86 · blue #2959aa
yellow #8f5e15 · teal #33635c · green #385f0d
---
Fetched 2026-09-06 from the projects' own repositories, same rule as above.
Where a project publishes fewer background tiers than ihasmail needs, the
missing one is derived and marked **derived** here rather than passed off as
upstream. Body text is lifted to 7:1 by the build script for most of these —
they target their own ~4.5:1 — and every shift is printed in the generated CSS.
## Catppuccin — catppuccin/palette, MIT (palette.json)
Cited from the palette repo rather than the hub README; it is the normative
machine-readable source.
### Mocha (dark)
base #1e1e2e · mantle #181825 · crust #11111b · surface0 #313244 · surface1 #45475a
text #cdd6f4 · subtext0 #a6adc8 · overlay1 #7f849c
mauve #cba6f7 · blue #89b4fa · red #f38ba8 · peach #fab387 · green #a6e3a1
yellow #f9e2af · pink #f5c2e7
### Latte (light)
base #eff1f5 · mantle #e6e9ef · crust #dce0e8 · surface0 #ccd0da · surface1 #bcc0cc
text #4c4f69 · subtext0 #6c6f85
mauve #8839ef · blue #1e66f5 · red #d20f39 · peach #fe640b · green #40a02b
yellow #df8e1d · pink #ea76cb
Latte publishes no tier lighter than `base`, so `base` is used as the elevated
surface and `mantle` as the page behind it.
## Solarized — altercation/solarized, MIT (README "The Values")
base03 #002b36 · base02 #073642 · base01 #586e75 · base00 #657b83
base0 #839496 · base1 #93a1a1 · base2 #eee8d5 · base3 #fdf6e3
yellow #b58900 · orange #cb4b16 · red #dc322f · magenta #d33682
violet #6c71c4 · blue #268bd2 · cyan #2aa198 · green #859900
The accents are shared by both modes by design. Two tiers are **derived**: the
sunken dark surface #001f28 (below base03) and the raised light surface
#fffdf6 (above base3), neither of which Solarized publishes, plus the two
rule colors #0d4552 and #e6dfc8.
## Everforest — sainnhe/everforest, MIT (palette.md), medium contrast
### Dark
bg_dim #232a2e · bg0 #2d353b · bg1 #343f44 · bg3 #475258
fg #d3c6aa · gray1 #859289
red #e67e80 · orange #e69875 · yellow #dbbc7f · green #a7c080 · aqua #83c092
blue #7fbbb3 · purple #d699b6
### Light
bg_dim #efebd4 · bg0 #fdf6e3 · bg3 #e6e2cc · bg5 #bdc3af
fg #5c6a72 · gray1 #939f91
red #f85552 · orange #f57d26 · yellow #dfa000 · green #8da101 · aqua #35a77c
blue #3a94c5 · purple #df69ba
Light uses bg_dim as the page and bg0 as the raised surface, so the card the
reader looks at is the color Everforest calls its background.
## Kanagawa — rebelot/kanagawa.nvim, MIT (lua/kanagawa/colors.lua)
### Wave (dark)
sumiInk0 #16161D · sumiInk3 #1F1F28 · sumiInk4 #2A2A37 · sumiInk5 #363646
fujiWhite #DCD7BA · fujiGray #727169
crystalBlue #7E9CD8 · springBlue #7FB4CA · samuraiRed #E82424 · roninYellow #FF9E3B
springGreen #98BB6C · carpYellow #E6C384 · sakuraPink #D27E99
### Lotus (light)
lotusWhite0 #d5cea3 · lotusWhite1 #dcd5ac · lotusWhite2 #e5ddb0 · lotusWhite3 #f2ecbc
lotusInk1 #545464 · lotusGray2 #716e61
lotusViolet4 #624c83 · lotusBlue4 #4d699b · lotusRed #c84053 · lotusOrange #cc6d00
lotusGreen #6f894e · lotusYellow #77713f · lotusPink #b35b79
## Ayu — ayu-theme/ayu-colors, MIT (themes/dark.yaml, themes/light.yaml)
The YAMLs give the base palette and the surfaces as literals but express syntax
roles as references (`$palette.indigo.l2`), and the resolved files are not
committed. The two signature accents are taken from the same organization's
MIT-licensed ayu-theme/vscode-ayu build.
### Dark
surface base #0D1017 · lift #10141C (sunk is `base -L0.1`, **derived** here as #070a0f)
ui line #1B1F29 · ui fg #5A6378 · editor fg #BFBDB6
red #F07178 · orange #FF8F40 · yellow #FFB454 · green #AAD94C · teal #95E6CB
indigo #39BAE6 · blue #59C2FF · purple #D2A6FF · accent #E6B450 (vscode-ayu)
### Light
surface sunk #EBEEF0 · base #F8F9FA · lift #FCFCFC
ui fg #828E9F · editor fg #5C6166 · rule #dfe2e5 (**derived**)
red #F07171 · orange #FA8532 · yellow #EBA400 · green #86B300 · teal #4CBF99
indigo #55B4D4 · blue #22A4E6 · purple #A37ACC · accent #F29718 (vscode-ayu)
## Primer — primer/primitives, MIT (src/tokens/base/color/{dark,light})
Named "Primer" after the design system. The color values are MIT; "GitHub"
and the Invertocat are trademarks, and nothing here is endorsed by them.
### Dark
neutral #0D1117 #151B23 #212830 #262C36 #2A313C #2F3742 #3D444D #656C76
#9198A1 #B7BDC8 #D1D7E0 #F0F6FC · black #010409
blue #79c0ff #58a6ff · green #56d364 #3fb950 · yellow #e3b341 #d29922
red #ff7b72 · purple #d2a8ff
### Light
neutral #F6F8FA #EFF2F5 #E6EAEF #E0E6EB #DAE0E7 #D1D9E0 #C8D1DA #818B98
#59636E #454C54 #393F46 #25292E
blue #0969da #0550ae · green #1a7f37 #116329 · yellow #bf8700 #9a6700
red #cf222e · purple #8250df
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**johnellisATlinuxDOTcom**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+222
View File
@@ -0,0 +1,222 @@
# Contributing to ihasmail
Thanks for your interest in contributing to **ihasmail** — an immutable, JMAP-only webmail client for [Stalwart Mail Server](https://stalw.art/). Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
## Code of Conduct
By participating in this project, you agree to treat other contributors with respect. Be constructive, be patient with newcomers, and keep discussion focused on the project. Harassment or abusive behavior toward other contributors will not be tolerated.
## Before You Start
- ihasmail speaks **JMAP only** — it does not support IMAP/POP3/SMTP fallback paths. Keep this in mind when proposing features.
- ihasmail has **no database of its own** — all state lives in Stalwart via JMAP. Contributions should not introduce a separate persistence layer without discussion first.
- This project is licensed under **AGPL-3.0**. Any code you contribute will be distributed under this license, including for hosted/SaaS deployments.
## How to Contribute
### Reporting Bugs
Before opening a new issue, please search [existing issues](https://github.com/Coffey-Labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
- A clear, descriptive title
- Steps to reproduce the issue
- Expected behavior vs. actual behavior
- Your environment: browser/OS, Stalwart version, and how ihasmail is deployed (Docker, bare metal, etc.)
- Relevant logs, console errors, or screenshots
- Whether the issue is reproducible against a fresh Stalwart instance
### Suggesting Features
Open an issue describing:
- The problem you're trying to solve (not just the solution)
- How it fits with ihasmail's JMAP-only, nothing-to-persist design
- Any relevant JMAP RFC references (RFC 8620, RFC 8621) if the feature touches protocol behavior
For larger changes, please open an issue to discuss the approach **before** submitting a pull request — this saves everyone time if the direction needs adjusting.
### Submitting Pull Requests
1. **Fork** the repository and create your branch from `main`.
2. **Name your branch** descriptively, e.g. `fix/thread-view-scroll` or `feat/search-filters`.
3. **Keep PRs focused** — one logical change per PR. Large, unrelated changes bundled together are harder to review and more likely to be rejected.
4. **Write clear commit messages** describing what changed and why.
5. **Test your changes** against a real (or local) Stalwart instance where possible, since JMAP behavior can be subtle.
6. **Update documentation** if your change affects setup, configuration, or user-facing behavior.
7. **Open the pull request** against `main`, filling out the PR template with:
- A summary of the change
- Related issue number(s), if any
- Screenshots/GIFs for UI changes
- Any manual testing you performed
8. **Add translations** for any new user-visible string — see
[Translations](#translations) below — and **drive the built app** for any
change that is visible on screen, as described in
[Verifying UI work](#verifying-ui-work).
`main` is protected. A change reaches it through a pull request whose **build**
check has passed — not afterwards — and the branch cannot be force-pushed or
deleted. No approving review is required, so a PR of your own is not blocked
waiting for one.
**CI on a PR from a fork waits to be approved.** Every workflow run on an
outside contributor's branch sits at *awaiting approval* until a maintainer
starts it by hand, so the **build** check will not appear the moment you open
the PR — that is the gate working, not a broken run. Pushing again will not
start it, and neither will closing and reopening.
### Code Style
- Match the existing formatting and naming conventions used elsewhere in the codebase.
- Keep functions small and single-purpose where practical.
- Prefer clarity over cleverness — this is a mail client people rely on for their inbox.
- Comment non-obvious JMAP interactions, especially around state/`changes` handling, since JMAP's delta-sync model can be easy to get subtly wrong.
### Translations
Nine languages ship alongside English: German, Spanish, French, Dutch,
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, in
`web/src/locales/`. A missing key renders its English source rather than
failing, so an untranslated string is invisible until somebody reading that
language finds it.
**Any change that adds or alters a user-visible string adds work in all nine
catalogs.** Say so explicitly in the PR — how many keys, and the fallback
count before and after — and say so just as explicitly when a change adds none,
so it is never left to be inferred.
#### The catalog key for a plural is the `other` form
`plural()` looks the entry up by `forms.other`, so a call site written as
```ts
plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
```
is keyed on **`"Deleted {n} contacts"`**. Keying the catalog on the `one`
form type-checks, builds, passes every test, and silently falls back to English
in all nine languages. Nothing errors. The only signal is the fallback count
going up, so read it:
```sh
npm run i18n:check # literals wrapped, and catalog health; exits 1 on a finding
node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back
```
Compare the "falling back to English" number against `main` before and after.
It should not rise. Do not read the percentage instead — adding keys moves the
denominator, so it can hold steady while new strings go untranslated.
Plural forms are per language, from `Intl.PluralRules`: `one`/`other` for most,
`one`/`few`/`many`/`other` for Russian and Ukrainian, `other` alone for Japanese
and Chinese. Supplying a form a language does not draw is inventing a
distinction, not being thorough.
### Verifying UI work
Store tests do not exercise the component. At least one bug in this repo's
history — a shift-click range measured inside a `setState` updater, which React
runs after the anchor ref has already moved — passed every store assertion and
failed the moment the built app was driven. If a change is visible on screen,
run it: `npm run dev:mock` (mock Stalwart, credentials printed on start), then
drive the real thing. Add a component test for what you find; there are
examples in `web/src/views/*/__tests__/`.
### Development Setup
1. Clone your fork:
```bash
git clone https://github.com/YOUR-USERNAME/ihasmail.git
cd ihasmail
```
2. Point your local instance at a running Stalwart Mail Server (a test/dev instance is strongly recommended — do not develop against a production mailbox), or use the built-in mock below.
3. Install and run, as below.
4. Verify your changes don't break existing JMAP calls by exercising core flows: login, list/read mail, send, search, and folder/label operations.
Requirements: Node ≥ 20.19 (26 recommended), npm ≥ 10.
```bash
npm install
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
npm run typecheck # tsc for both packages
npm test # vitest (web) + node:test (server)
npm run build # web/dist + server/dist
npm start # serve the production build
```
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
build.
#### Architecture
```
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
React SPA • session cookie ⇄ Basic auth
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
```
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitizer, search parser, Sieve codec, locale-aware dates, vCard, …).
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos.
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
`urn:stalwart:jmap`. Features degrade gracefully when one is missing.
#### The mock
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
without a real mailbox. It reproduces the things a naive fake would get wrong,
because each cost a live debugging session: `urn:stalwart:jmap` advertised
**per-account** rather than session-level, identity signatures capped at 2047
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
RFC 8984's.
| Switch | What it does |
| --- | --- |
| `MOCK_NO_FUTURE_RELEASE=1` | Advertises FUTURERELEASE, then drops every hold |
| `MOCK_NO_REGISTRY=1` | Omits the Stalwart capability, so the sign-in refusal can be tested |
| `MOCK_NO_SCHEDULING_SEND=1` | Refuses a calendar write that asks for scheduling messages, as for an account without that permission |
| `MOCK_ROLE` | Who the demo user is for Administration: `admin` (the default), `tenant-admin`, `helpdesk` or `user` |
| `MOCK_METRICS=off` | Refuses the dashboard's metric history, as Community does |
| `MOCK_EDITION=enterprise` | Reports Enterprise, which Tenants needs |
It tracks the current Stalwart release rather than 0.16 in general, and each
behavior is confirmed against a real server before it is copied here — the
comments say which version and on what date. Where a release changes something
a client can see, the mock changes with it, and the test that pinned the old
behavior is rewritten rather than deleted, so the reversal stays on the record.
#### Version numbers
`2026.8.30+pr129` is the date of the commit a build came from and the pull
request that commit arrived through; a commit that did not come through one
carries its short SHA instead (`2026.8.30+g1fa6578`). It is worked out from git
at build time — nothing writes a version into the tree, and `package.json` stays
at `0.0.0`. `node scripts/version.mjs` prints it for the current checkout.
The PR number sits after the `+` as build metadata because it records where a
build came from, not how new it is. The version says nothing about Stalwart on
purpose: what a build needs from the server is stated in the README badge and
in [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Building an image with the version on it,
and the single-host `deploy.example.sh`, are covered in
[Installing](https://docs.ihasmail.org/install/).
## Review Process
- A maintainer will review your PR and may request changes.
- Please respond to review feedback in a timely manner; PRs with no activity for an extended period may be closed and can be reopened once updated.
- Once approved, a maintainer will merge the PR.
## Reporting Security Issues
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **johnellisATlinuxDOTcom** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
## Questions?
If you're unsure whether something is a good fit, open an issue and ask — discussion is welcome before you invest time in a PR.
Thanks again for helping improve ihasmail!
+9
View File
@@ -0,0 +1,9 @@
# Example reverse proxy (Caddy) in front of ihasmail.
# TLS is automatic. ihasmail sets Secure cookies and HSTS when X-Forwarded-Proto is https.
mail.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080 {
# Keep SSE (push) connections open
flush_interval -1
}
}
+81 -14
View File
@@ -1,16 +1,83 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
# ---- build stage ----
FROM node:26-alpine AS build
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
# context on purpose, and git is not installed either. `node scripts/version.mjs`
# in a checkout prints the right answer; ihasmail-deploy.sh passes it through.
# Left empty, the build falls back to the base version from package.json.
ARG IHASMAIL_VERSION=""
ENV IHASMAIL_VERSION=$IHASMAIL_VERSION
# The subpath the app will be served from, e.g. /mail. Empty -- the default --
# is the domain root and is what every deployment gets unless it asks
# otherwise. Unlike the rest of ihasmail's configuration this cannot wait for
# the process to start: the web build writes its own asset URLs into
# index.html, so a build that does not know the prefix produces a shell that
# cannot load itself under one. It is therefore a build argument here and an
# environment variable in the runtime stage, from the same value.
ARG BASE_PATH=""
ENV BASE_PATH=$BASE_PATH
WORKDIR /app
COPY package.json package-lock.json* ./
COPY server/package.json server/
COPY web/package.json web/
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
COPY pyproject.toml README.md /app/
RUN pip install --no-cache-dir -e .
COPY app /app/app
COPY .env.example /app/.env.example
ENV PORT=8000
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host=0.0.0.0", "--port=8000"]
# ---- runtime stage ----
FROM node:26-alpine AS runtime
# Re-declared: an ARG does not cross stages.
ARG IHASMAIL_VERSION=""
ARG BASE_PATH=""
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8080 \
STATIC_DIR=/app/web/dist \
SESSION_FILE=/data/sessions.json \
IHASMAIL_VERSION=$IHASMAIL_VERSION \
BASE_PATH=$BASE_PATH
WORKDIR /app
COPY package.json package-lock.json* ./
COPY server/package.json server/
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
# set it never looks further; without it, it falls back to package.json rather
# than failing, since there is no git in here to ask.
COPY scripts/ ./scripts/
# Only what the server loads at runtime: hono and its Node adapter, about 4 MB.
# The build stage's tree is 132 MB of vite, TypeScript, esbuild and React that
# never executes here but shipped anyway -- and showed up in every CVE scan.
RUN npm ci --ignore-scripts --omit=dev --workspace server \
&& rm -rf /root/.npm /tmp/*
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/dist
# /data is the only path the process may write. /app stays root-owned and
# read-only to the runtime user on purpose; the previous `chown -R /app`
# re-wrote every file and, on overlayfs, duplicated the whole tree into a
# second 173 MB layer.
RUN mkdir -p /data && chown node:node /data \
# The base image ships a package manager the server never calls. Anyone who
# gets code execution should not find one waiting for them.
&& rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \
/usr/local/bin/corepack /opt/yarn* /usr/local/bin/yarn /usr/local/bin/yarnpkg
USER node
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
# goes, but Docker acts on it: a container started without `-v` gets an
# anonymous volume mounted there anyway, and that mount stays writable even
# under `--read-only`. So the directive quietly put a writable hole in a
# container meant to be immutable, and left an orphaned volume behind every
# time one was replaced -- while never persisting anything across a redeploy,
# since each new container got a fresh empty volume of its own. Deployments
# that want the sessions to survive say so themselves: docker-compose.yml and
# deploy.example.sh both mount a *named* volume at /data, which is unaffected.
EXPOSE 8080
# Shell form, so $BASE_PATH is expanded by the container rather than baked in
# empty at build time: the health endpoint moves with the mount.
#
# The two substitutions repeat, in sh, what scripts/basePath.mjs does in
# JavaScript -- drop a trailing slash, add a leading one -- because this runs
# before there is a Node process to ask. It is worth the duplication: an
# operator who writes BASE_PATH=mail/ gets a working server, and without this
# a healthcheck that says the working server is unhealthy and has Docker
# restart it forever.
HEALTHCHECK --interval=30s --timeout=5s CMD BP="${BASE_PATH%/}"; case "$BP" in ""|/*) ;; *) BP="/$BP";; esac; wget -qO- "http://127.0.0.1:8080$BP/api/health" || exit 1
CMD ["node", "server/dist/index.js"]
+1881
View File
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
# Known issues and pending QA
What was checked, against which server, and when. For a failure you are hitting
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
The live instance runs **0.16.22**, and as of **2026-08-26 there is nothing
left pending**. Most entries below were exercised against 0.16.19 on the date
they name, and the dates still say so: each upgrade since was read against the
diff rather than re-run, and nothing in those diffs touches the session
capabilities, blob, quota, submission or registry paths these entries describe.
The calendar entries carrying a 2026-08-31 date were exercised against a live
0.16.20 directly, as were the public-key entries dated 2026-09-05.
**0.16.21 was different and was re-run rather than read.** It changed four
things a client can see, one of which resolved an entry below outright: an
occurrence of a recurring event is identified by its recurrence id rather than
its position in the series, so an id held across a write no longer names a
different date; `Calendar/get` and `AddressBook/get` return every property when
none are named; EventSource advertises its ping interval in seconds rather than
milliseconds; and a calendar write that asks for scheduling messages is refused
when the account may not send them. The mock reproduces all four. The app
was run against a real 0.16.21 with mail, calendar and contacts exercised by
hand, including editing one occurrence of a recurring series through the
interface and confirming the rest of the series stayed where it was.
**0.16.22 (2026-09-13) was tested too.** The app has been tested against it on
the live instance. Its changes a client can see are all in `CalendarEvent/get`
and `ContactCard/get`, and were read from its source before the mock was made
to follow them: `baseEventId` is `null` for an event read by its stored id,
`recurrenceRule` and `recurrenceOverrides` asked for on a synthetic id come back
`null`, `useDefaultAlerts` belongs to the reader and reads `false` until set,
and an empty `properties` list returns `id` alone. None of them contradicts an
entry below.
What remains here is not a list of unknowns but of things worth knowing — where
Stalwart departs from a spec, where a setting has to be turned on for a feature
to work, and what ihasmail deliberately does not do.
Entries keep saying what was checked and when, because this section has been
wrong before: the 0.16 registry path was once recorded as verified live when a
capability looked for in the wrong place meant it had never run at all.
Some entries record what a live **0.15.5** proved before that server was
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
rather than about 0.15 — a byte cap that still applies, a flow that still
works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)).
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
- **The Basic credential ihasmail proxies with reaches the admin `x:` methods**, as it already reached the self-service ones. No separate token is involved.
- **An account reads back in the shapes the code expects**: `credentials` as `{"0": {"@type": "Password", …}}`, aliases and group memberships as objects, the disk limit under `quotas.maxDiskQuota`.
- **A new domain gets automatic DKIM straight away** — an Ed25519 and an RSA key, both `active`, with their records already in the zone file — and manual DNS and certificates.
- **`dnsZoneFile` is BIND text**, one record per line as `name IN TYPE value`, with a long TXT record split into a parenthesized run of quoted chunks. A throwaway domain's file held 18 records and 3 continuation lines; every record parsed and the panel showed 18 rows. The production domains also carry TLSA records, which show as rows like any other.
- **`x:DkimSignature/query` accepts a `domainId` filter.**
- **`catchAllAddress` wants a whole address.** A bare local part is refused with `invalidPatch`, *"Invalid email address"*.
- **A domain its keys still name cannot be destroyed**: `objectIsLinked`, with `linkedObjects` listing each as `{"object": "DkimSignature", "id": …}` and no description. Removing through the panel destroys the keys first and then the domain; both were gone afterwards.
- **A reserved TLD is refused**: `example` as a domain's top level comes back `invalidPatch`, *"Invalid domain name"*, naming `name`.
The last two were then tried by hand on the live server the same day and behaved as described. **A password set by an administrator** — written to the account's existing credential, `credentials/<index>/secret` — signs in. **The outranking guard** held: an account with more rights than the viewer's role opens read-only. The guard exists because the source shows Stalwart skipping its grant check when only a password changes and on a delete, and it stays for that reason.
- **The dashboard's feeds were settled on the live server before the code was written (2026-09-15, 0.16.22 Enterprise, read-only calls from an administrator's session).** The first probes guessed two of these wrong — filtering on `timestamp`, and counting received mail from `message-ingest.*` — and the server and the 0.16.22 source agreed on the answers below:
- **The metric history filters on comparison names.** `x:Metric/query` accepts `{"timestampIsGreaterThanOrEqual": …, "metric": [names]}`; a bare `timestamp`, `after` or `metric` as a string is `unsupportedFilter`. Sorting on `timestamp` works. At the default interval a day is about 80 records for the six metrics the dashboard reads, and a get takes at most 500.
- **Received and sent are `queue.*` counters**, not `message-ingest.*`: `queue.message-queued` for received, and `queue.authenticated-message-queued` + `queue.dsn-queued` + `queue.report-queued` for sent, which is what Stalwart's own dashboard adds up. A Counter holds its interval's count and a zero one is not written; the `*-time` histograms are cumulative, which is why nothing reads them.
- **Memory is the `server.memory` Gauge**, in bytes, one per interval. **Counts** come from `/query` with `calculateTotal: true` and `limit: 0`, which returned the whole total for `x:Account` (users only, via `@type`), `x:Domain` and `x:QueuedMessage`.
- **`x:Metrics/get` is not the history.** It is the singleton holding the collection settings (Prometheus and OpenTelemetry export, the metrics policy); the history is `x:Metric`.
**Not confirmed live:** that a tenant administrator's counts are scoped to the tenancy, and that a Community server refuses `x:Metric` as `forbidden`. Both are read from the 0.16.22 source (`query.rs`, `queued_message.rs`, `registry/mod.rs`); the production server has no tenants and is Enterprise, so neither could be tried there without writing. The dashboard's handling of both is covered by tests against the refusal Stalwart's source gives.
- **Groups were built from the 0.16.22 source and a mock, then confirmed on the live server (2026-09-15)** with a throwaway group on one of the server's domains, created and removed, its only member the administrator's own account:
- **A group is created** as `x:Account` with `@type: "Group"`, no credentials and no encryption setting, and reads back with roles `{"@type": "Default"}`, `permissions` `Inherit`, a `locale` of `en-US` and `usedDiskQuota` 0.
- **Membership is the member's.** `"memberGroupIds/<group>": true` on the user was accepted; `{"@type": "User", "memberGroupIds": <group>}` then found them with a total of 1, and the user's own `memberGroupIds` read `{"<group>": true}`. The same pointer with `null` took them out again and left the set as it was before.
- **A group with members cannot be deleted**: `objectIsLinked`, with `objectId` as `{"object": "Account", "id": <group>}` and `linkedObjects` listing each member as `{"object": "Account", "id": …}`. With the member out, the delete went through and the group read back as `notFound`.
The same run tried a throwaway mailing list before the Mailing lists section was written. It was created with `recipients` as a set, `{"[email protected]": true}`, and read back as `name`, `domainId`, `description`, `aliases`, `memberTenantId`, `recipients` and a computed `emailAddress`. `"recipients/<address>": true` added one and left the other; a `text` filter found it; it was destroyed with nothing linked. Not tried: removing a recipient with `null` (the same set patch as a group membership, which was), and how the server words a recipient that is not an address.
Still from source only: that membership gives a member no permissions (`access_token.rs` builds a user's permissions from their own roles), and that groups cannot nest.
- **Roles were built from the 0.16.22 source, its schema and the mock, then confirmed on the live server (2026-09-15)** with throwaway `ihasmail-role-test` roles, created and removed:
- **A role is created** with `description`, `roleIds`, `enabledPermissions` and `disabledPermissions` as sets, and reads back with them and `memberTenantId`.
- **Pointers change one entry each**: `enabledPermissions/<p>` and `disabledPermissions/<p>` with `true` or `null`, `roleIds/<id>` likewise, and `description` in the same update, all applied together.
- **A name that is not a permission fails the whole update** as `invalidPatch`, *"Invalid value for object property"*, naming the pointer — which is how a probe using the mock's made-up `jmapEmailSet` found that the mock had carried a permission Stalwart does not have since Accounts was built; it is `jmapEmailUpdate` now, and the mock refuses unknown names.
- **A grant the caller does not hold is refused**: `forbidden`, *"You are not authorized to grant permissions: scimAccess"*.
- **A role another role builds on cannot be deleted**: `objectIsLinked`, `objectId` `{"object": "Role", …}`, `linkedObjects` naming the child.
- **The defaults** read from `x:Authentication`: users get User; groups get Group; tenant administrators get Tenant Administrator and User; administrators get System Administrator and User.
**The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs toward refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` through ihasmail's server was confirmed on production after the deploy (2026-09-15, v2026.9.15+pr364)**: `/api/admin/permissions` answered 200 with all 661 permissions, the same list as the 0.16.22 snapshot, and the Roles picker drew them under 60 headings. The four bootstrap roles grant 244 (User), 229 (Group), 50 (Tenant Administrator) and 452 (System Administrator) once their trees are followed.
- **Tenants were built from the 0.16.22 source, its schema and the mock, then tried on the live server (2026-09-15)** with throwaway `ihasmail-tenant-test` tenants, a throwaway role, two throwaway lists and a throwaway domain, all removed. The live run changed the design twice:
- **A tenant is created and edited as built**: `name`, `logo`, `roles`, `permissions`, `quotas`; `quotas/<name>` pointers, a logo and a rename in one update; an unknown quota name is `invalidPatch`.
- **Something in a tenant has to be on a domain in that tenant.** A list in the tenant on a domain in none was refused, `invalidForeignKey` with `objectId` `{"object": "Domain", …}`; the same list on a domain created in the tenant was accepted — and so was a list in *no* tenant on that domain. **So an account's tenant choice offers only its domain's tenant**, and a new account starts in the tenant of the domain it is made on.
- **A domain created in a tenant puts its DKIM keys in the tenant too**, and they stay there. They count against `maxDkimKeys` and keep the tenant from being deleted, so they are counted with everything else.
- **Stalwart lets a domain leave a tenant while the tenant still has things on it**, leaving them in a tenant on a domain outside it. **The panel refuses to take a domain out while any of the tenant's accounts are on it.** Mailing lists cannot be filtered by domain, so a list is not checked.
- **A tenant still holding anything is kept**: `objectIsLinked`, `objectId` `{"object": "Tenant", …}`, `linkedObjects` naming a role, a list and DKIM keys. A role set to `memberTenantId: null` left it, after which the tenant was deleted.
Still from source only: that only a caller outside every tenant may set `memberTenantId` (`set.rs` passes `can_set_tenant` only when the token has no tenant), and that a tenant administrator's queries are scoped to the tenant. On a server that does not report Enterprise the Tenants page is only its notice.
- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalog's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart.
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognized by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
- **Administration is off for a device not marked as your own, and for an installation that says so.** Both are enforced by the server rather than hidden by the menu: such a session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. That is worth stating because the proxy otherwise forwards whatever the browser sends, and before these gates an administrator's console could make any registry call their role allowed. For a session that may not administer, the proxy reads a request body only when it could name a registry method — a `"x:` in the text, or a `\u` escape that could spell one — so ordinary mail traffic is forwarded untouched.
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localize and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogs and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalog key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalog can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalog key, although no component here runs its props through `t()`, so 19 strings with translations in every catalog (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogs. English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``, was the last gap. It can't be a catalog key as written. Since 2026-09-14 the check flags any template literal in one of these positions that has words between its values, and the twelve that existed are now keys with placeholders. They were the quota bar, the address menu, a folder's unread count, the recipient chips, the contact editor's title, shared calendars and address books, the date and time fields, the attachment fallback name, and the free/busy bar. That bar showed the raw JMAP value (`confirmed`) in every language.
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock omitted it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server. **0.16.21 fixed this for calendars and address books**: with `properties` omitted, `Calendar/get` and `AddressBook/get` now return every property, `shareWith` included — **confirmed live on 0.16.21 (2026-09-06)**. `Mailbox/get` on the same server still leaves it out, so the mock now hides it for mail folders alone, and ihasmail keeps naming the property everywhere.
- **Stalwart's `x:PublicKey` registry works, and ihasmail deliberately does not expose it.** A Settings section for it has been built twice — [PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), closed 2026-08-26, and [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285) — and withdrawn both times, for a reason that has nothing to do with the server: **nothing in ihasmail signs, encrypts, decrypts or verifies with a key**, so a page for managing them is furniture rather than a feature. It ends up telling the reader, in its own footnote, that adding a key does nothing. The registry is written up here rather than in [ROADMAP.md](ROADMAP.md) because what follows is established fact about Stalwart that cost a live probe, and losing it twice to a closed pull request was how the second attempt came to exist at all. Everything below was **confirmed live on 0.16.20 (2026-09-05)** from a normal account with no administrative rights, and the full round trip — create, read back, rename, patch, destroy — succeeded for both formats.
- **An ordinary user may read *and* write their own keys**, whatever the permissions table says: Stalwart documents every `sysPublicKey*` permission as administrative, and the server granted them anyway. A create carrying a malformed key was refused with `invalidProperties` naming `key` rather than `forbidden` — a rejection of the key, not of the person. Had the documentation been right, any such feature would have been useless to everybody but an administrator, which is why this was probed first.
- **It takes S/MIME certificates as well as OpenPGP keys, and parses both.** A self-signed X.509 certificate carrying `emailProtection` and an `email:` SAN registered, read back and destroyed cleanly, and a malformed one is refused by a decoder of its own: *"Failed to decode X509 certificate: BER decoding error: Expected Tag { class: Universal, value: 16 } tag…"*. Worth checking rather than assuming, because every *other* message the registry returns names OpenPGP — including for input that is not OpenPGP at all — so the server reads as though OpenPGP were the only format it knows. It is not.
- **A key can parse perfectly and still be refused, and says something different when it is.** A sign-and-certify OpenPGP key with no encryption subkey — which is what `gpg --quick-generate-key` produces — comes back *"Could not find any suitable keys in OpenPGP public key"*, distinct from the parser's *"Failed to decode OpenPGP public key: Malformed packet: Malformed CTB…"*. Any client showing these must keep them apart: one says paste it again, the other says the key needs an encryption subkey and no amount of care with the clipboard will help. Certificates have no equivalent trap, since one issued for email use has key encipherment by construction.
- **`emailAddresses` comes back as `{}` when empty** — an object, where a JMAP list property should be an array. Nothing fails loudly: it is a plain `Get` response that type-checks against a hand-written interface and then throws in `join()` while a list renders. A client must check the shape rather than trust the type.
- **A create answers with the id alone**, no `createdAt`, so anything that reads the date back out of the create response gets `undefined`. **Patching `key` on an existing entry is allowed**, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keeps `createdAt` meaning what it says.
- **`expiresAt` is the registry's own field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
- **Signature checking is done here, and its trust model is deliberately small.** Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does *not* do, because the gap is a design choice rather than an omission. **No chain of trust is validated** — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is gray rather than green, and why a changed signer never overwrites the pin. Verified against real `openssl smime -sign` output rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be.
- **OpenPGP signatures cannot be checked at all, for a reason that is not effort.** A PGP signature carries no key, so verifying one needs the sender's public key in advance, and there is nowhere to get it: `x:PublicKey` holds the *account's own* keys, not correspondents'. Fetching from a keyserver or via WKD would tell a third party who you correspond with each time you opened a message — the same leak the image proxy exists to close — so it is not done. Such a message says so by name rather than failing as an unknown format, and it says *could not check* rather than *did not check out*, which is a distinction worth keeping: one is ignorance and the other is an accusation.
- **Two signature shapes are declined rather than attempted.** SHA-1 signatures are refused outright — one nobody can forge in practice today is still not one to put a tick beside. RSA-PSS is declined because the salt length lives in parameters ihasmail does not read, and guessing wrong would report a perfectly good signature as *bad*, which is a far worse thing to say than "cannot check". Both are shown as uncheckable, not as broken.
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognize ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **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 of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` 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; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honors a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, canceled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a canceled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Canceling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. Since 0.16.22 the same event read by its *stored* id answers `baseEventId: null` rather than its own id, which changes nothing here: a one-off read through the synthetic id an expanded query gave it still carries a base.
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email`**no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behavior and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
- **A synthetic id was only true until the next write, through 0.16.20. Fixed in 0.16.21.** Stalwart's expanded-occurrence ids used to encode a position in the series, so writing a `recurrenceOverrides` entry renumbered them. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier, so an id cached across a write silently pointed at another date and a delete meant for one occurrence removed a different one. The failure was never a `notFound` a client would notice; it was a confident answer about the wrong day. **0.16.21 identifies an occurrence by its recurrence id, and confirming that was the point of re-running rather than reading the diff. Confirmed live on 0.16.21 (2026-09-06)**: the same shape of test — five weekly occurrences expanded, the third retitled through its own synthetic id, all five original ids re-read — left every id on its own date, with none renumbered and none `notFound`. A second override written through the interface behaved the same way. The defense stays regardless: ihasmail still never mutates an occurrence by an id it is holding, and `updateEvent` and `destroyEvent` still re-resolve by `recurrenceId` immediately before acting, because a date can still leave a series and because the client supports 0.16 as a whole rather than only its newest release. The mock follows the new behavior, and the test that pinned the old renumbering now pins the stability instead — rewritten rather than deleted, so the reversal stays on the record.
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
- **Recurring events can be edited and deleted one date at a time, since 0.16.20.** A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a `recurrenceOverrides` entry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. **Confirmed live on 0.16.20 (2026-08-31)** end to end against a five-week series: a legal patch landed on the override with `start` and `duration` filled in by the server; `useDefaultAlerts` was refused with *"This property cannot be modified on a single occurrence."*; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with *"A base event and its instances cannot be modified in the same request."* The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the *series'* start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up.
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
+657 -12
View File
@@ -1,16 +1,661 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2025 John Coffey
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
Preamble
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
-13
View File
@@ -1,13 +0,0 @@
.PHONY: run dev test build
run:
uvicorn app.main:app --host 0.0.0.0 --port 8000
dev:
uvicorn app.main:app --reload
test:
pytest
build:
docker compose build
+96
View File
@@ -0,0 +1,96 @@
# Third-party notices
ihasmail is licensed under the AGPL-3.0; see LICENSE. This file records work by
other people that ships inside it and the terms it comes under.
## Color palettes
Ten of the palettes offered in Settings Appearance are the work of their own
projects and are used under the MIT license. Only the published color values
are used — no code, and nothing from anyone else's reimplementation of them.
The values as fetched from each project are recorded in
`.palette-sources/palettes-upstream.md`, and the shades between them are
derived by `scripts/build-palettes.py`, which also lifts any tier that would
not meet the contrast ihasmail claims.
### Dracula and Alucard
Copyright (c) 2016 Dracula Theme — https://github.com/dracula/dracula-theme
Licensed under the MIT license. "Dracula" is the dark variant and "Alucard" the
light one; both are published in that repository's own "Color Palette (OSS)"
section.
### Gruvbox
Copyright (c) 2018 Pavel Pertsev — https://github.com/morhetz/gruvbox
Licensed under the MIT license.
### Rosé Pine
Copyright (c) 2021 Rosé Pine — https://github.com/rose-pine/rose-pine-theme
Licensed under the MIT license. The light variant is "Dawn".
### Tokyo Night
Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme
Licensed under the MIT license. The light variant is "Day".
### Catppuccin
Copyright (c) 2021 Catppuccin — https://github.com/catppuccin/palette
Licensed under the MIT license. "Mocha" is the dark variant and "Latte" the
light one; both are published in that repository's palette.json.
### Solarized
Copyright (c) 2011 Ethan Schoonover — https://github.com/altercation/solarized
Licensed under the MIT license. Light and dark are both original to it, and
share one set of accent values by design.
### Ayu
Copyright (c) Konstantin Pschera — https://github.com/ayu-theme/ayu-colors
Licensed under the MIT license. The two signature accent colors come from the
same author's ayu-theme/vscode-ayu, also MIT.
### Kanagawa
Copyright (c) 2021 Tommaso Laurenzi — https://github.com/rebelot/kanagawa.nvim
Licensed under the MIT license. "Wave" is the dark variant and "Lotus" the
light one. The theme takes its name from Hokusai's print.
### Everforest
Copyright (c) 2019 Sainnhe Park — https://github.com/sainnhe/everforest
Licensed under the MIT license. The medium-contrast variant of each mode is
the one used here.
### Primer
Copyright (c) GitHub, Inc. — https://github.com/primer/primitives
Licensed under the MIT license, which covers the color values. "GitHub" and
the Invertocat logo are trademarks of GitHub, Inc.; this palette is named
"Primer" after the design system and is neither affiliated with nor endorsed
by GitHub.
---
The MIT license, under which all ten are used:
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+95 -41
View File
@@ -1,60 +1,114 @@
<p align="center">
<img src="web/public/img/logo.png" alt="ihasmail" width="150">
</p>
<p align="center">
<strong><a href="https://demo.ihasmail.com">Try the demo</a></strong><br>
<sub>A working copy with an invented mailbox behind it — no sign-up, nothing real, nothing kept.</sub>
</p>
<p align="center">
<a href="LICENSE"><img alt="License: AGPL-3.0-or-later" src="https://img.shields.io/badge/license-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.22" src="https://img.shields.io/badge/Stalwart-0.16.22-6366f1?style=flat-square"></a>
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
</p>
# ihasmail
![ihasmail logo](app/static/img/logo.png)
**Immutable webmail for [Stalwart Mail Server](https://stalw.art).** Mail,
calendars, contacts, files and filters in one app that works as well on a phone
as on a desktop — and a container with nothing to persist.
A polished, FastAPI + HTMX/Jinja webmail for Stalwart, with JMAP mail/contacts/calendar, Sieve UI, DAV browsing, and reverse-proxy friendly deploy.
ihasmail talks only JMAP to Stalwart. There is no database, no IMAP or SMTP,
and with `IMMUTABLE=1` no writable filesystem either: everything durable,
settings included, belongs to Stalwart, so the container is disposable.
A production-leaning, **FastAPI** + **HTMX/Jinja** webmail for [Stalwart Mail Server](https://stalw.art/), using **JMAP** for mail, contacts, and calendar, plus simple **WebDAV/CalDAV** helpers. Authenticates with the user's Stalwart mailbox (like Roundcube). Designed to run behind a reverse proxy.
| | |
| --- | --- |
| 🌐 **[ihasmail.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
| 📋 **[FEATURES.md](FEATURES.md)** | Everything it does, feature by feature, with the capability each one needs |
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
## Features
- Login with Stalwart mailbox (HTTP Basic against JMAP session or bearer token if provided)
- Inbox listing, read messages (plain text), compose & send via JMAP (`Email`, `EmailSubmission`)
- Contacts/Directory via JMAP `Contact`
- Calendar view via JMAP `CalendarEvent`
- WebDAV browser (read-only sample) and CalDAV endpoints (external DAV clients)
- CSRF on POST, signed session cookie, proxy-friendly
- Dockerfile + docker-compose for easy deploy
## Screenshots
> HTML rendering and attachment streaming are stubbed—extend using the JMAP `downloadUrl` and sanitize HTML before display.
| | |
| --- | --- |
| **Inbox & conversation (dark)** ![Inbox, dark theme](screenshots/inbox-dark.jpg) | **Inbox & conversation (light)** ![Inbox, light theme](screenshots/inbox-light.jpg) |
| **Composer** ![Composer](screenshots/compose.jpg) | **Calendar** ![Calendar](screenshots/calendar.jpg) |
| **Contacts** ![Contacts](screenshots/contacts.jpg) | **Sieve filter builder** ![Filters](screenshots/filters.jpg) |
## Quick Start (Docker)
Taken against the built-in mock with sample data. More, including the phone
layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
## What's in it
- **Mail** — conversations, labels, search operators, keyboard shortcuts, scheduled and undo send, invitations and RSVP, filters made from a message
- **Calendar** — month, week, day and agenda views, recurrence, attendees and free-busy
- **Contacts** — address books, groups, vCard import and export
- **Files** — browse, upload, move, share
- **Signature checking** — S/MIME signed mail verified as you read it
- **Settings that follow the account**, kept in the account's own storage on Stalwart
- **On a phone** — swipe to archive or delete, pull to refresh, hold to select
- **Administration** — a dashboard, accounts, groups, mailing lists, roles, tenants and domains, each shown only when the Stalwart role allows it
- **Ten interface languages and twelve themes** — the nine translations are marked Beta until a native speaker has read them
- **Platform** — installable PWA, Web Push, `mailto:` handler, no credentials in the browser, strict CSP
The long version is [FEATURES.md](FEATURES.md) and
[ihasmail.org](https://ihasmail.org/#features).
## Requirements
**Stalwart 0.16 or newer** — sign-in refuses anything older, by name. Tested
against 0.16.22; what changed in each release is in
[KNOWN-ISSUES.md](KNOWN-ISSUES.md).
- **No Stalwart yet?** [ihasmail-oneshot](https://github.com/Coffey-Labs/ihasmail-oneshot) deploys a new Stalwart and ihasmail together on one host, in one command.
- **On Stalwart 0.15?** [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) upgrades it in place, or stay on the [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support) release.
## Quick start (Docker)
```bash
# 1) Configure environment
cp .env.example .env
# Edit JMAP_BASE, CALDAV_BASE, WEBDAV_BASE, APP_SECRET
# 2) Build & run
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
docker compose up --build -d
# 3) Reverse proxy (Nginx/Caddy) to http://127.0.0.1:8080
# → http://localhost:8080 — put a reverse proxy in front for TLS
```
## Environment Variables
- `APP_SECRET` random string for signing cookies (required)
- `JMAP_BASE` e.g., `https://mail.example.com/jmap`
- `CALDAV_BASE` e.g., `https://mail.example.com/caldav/`
- `WEBDAV_BASE` e.g., `https://mail.example.com/webdav/`
- `COOKIE_NAME` cookie name (default: `stalwart_webmail`)
- `TRUST_PROXY` `1` to honor `X-Forwarded-*` (default: `1`)
- `UPSTREAM_TIMEOUT` seconds for upstream HTTP (default: `15`)
Or pull the published image, `registry.coffeylabs.org/coffey-labs/ihasmail`. Releases are
weekly, so it is usually a few days behind `main`.
People sign in with their Stalwart mailbox credentials. **An account with
two-factor authentication needs an app password**, created in Stalwart's own
settings.
Everything else — TLS, running immutably, several Stalwart servers, settings
the installation decides, every environment variable — is in
[Installing](https://docs.ihasmail.org/install/) and
[Configuring](https://docs.ihasmail.org/configure/).
## Development
## Dev
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
uvicorn app.main:app --reload
pytest
npm install
npm run dev:mock # built-in mock Stalwart ([email protected] / demo)
npm test
```
## Security & Hardening
- Prefer **bearer tokens** if Stalwart issues them; update `jmap_session()` to store `accessToken`
- Set explicit `accountId` from the JMAP session `primaryAccounts`
- Add mailbox/folder navigation via `Mailbox/query` + `Mailbox/get`
- Sanitize HTML bodies (e.g., `bleach`) before rendering
- Add Sieve UI via `urn:ietf:params:jmap:sieve`
- Consider rate limiting and security headers in the reverse proxy
- Serve static assets via proxy/CDN
Architecture, the mock's switches and how versions are numbered are in
[CONTRIBUTING.md](CONTRIBUTING.md#development-setup).
## Contributing
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
## License
GPL-3.0-or-later
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See [LICENSE](LICENSE).
If you run a modified ihasmail, set `SOURCE_URL` to your own repository: the
sign-in page and Settings About both show it. See
[Rebranding](https://docs.ihasmail.org/rebranding/).
+33
View File
@@ -0,0 +1,33 @@
# Roadmap / not yet
Things ihasmail does not do, and why. An issue number here says where the entry
came from, not that it is tracked elsewhere — a report can be closed because the
bug in it was fixed while the larger thing it asked for stays on this page. What
is genuinely open lives in [the issue tracker](https://github.com/Coffey-Labs/ihasmail/issues);
the rest is here because the answer is "no", not "not yet".
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
- **More of Stalwart's directory in Administration.** The Administration menu opens on a dashboard and manages accounts, groups, mailing lists, tenants, roles and domains today — see [FEATURES.md](FEATURES.md#administration). DNS and ACME providers are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalog without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
- **Signing and encrypting mail.** *Reading* a signature is built: S/MIME signed mail is checked as it is read, and the signer is remembered so a change is called out — see [Checking a signature](FEATURES.md#checking-a-signature). What is not built is anything that produces a signature or touches ciphertext, and the reason is not Stalwart. This is client work over the message body: JMAP hands over the MIME blob and the rest is ours.
The blocker is a security model, not code, and it is the same one it has always been. Signing and decrypting need a **private** key in a page served by the same host that would handle it, which runs straight into two things ihasmail says about itself: that it never stores a credential, and that it runs immutably with nowhere to keep one. Verifying needed none of that — the certificate travels inside the message — which is exactly why it could be built first and why it went first.
**OpenPGP signatures are not checked, and this is a harder problem than it looks.** A PGP signature does not carry the key, so verifying one means having the sender's public key already. ihasmail has no source for it: `x:PublicKey` is the account's *own* registry, and fetching from a keyserver or WKD would tell a third party who you correspond with, which is precisely the leak the image proxy exists to close. A local store of correspondents' keys is possible and is not a small feature; nobody has asked for it yet.
*Managing* keys — publishing your own to `x:PublicKey` — has been built twice ([PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285)) and withdrawn twice, because a Settings page for keys nothing uses is furniture. That reasoning is now partly spent: something does use a key. But what signature checking uses is the certificate inside the message, not anything in the registry, so publishing your own key remains a feature waiting for a consumer.
**Encryption at rest is refused rather than deferred.** Stalwart offers it as `encryptionAtRest`, a field on `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object whatever the docs suggest, and its value is a typed object (`{"@type": "Disabled"}`) rather than a bare string. It is self-service, needs no administrator, and would be easy to offer. It will not be: turning it *off does not decrypt what is already there*. Every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key, so switching it on is a one-way door — and a toggle that reads as "make my mail safer" while quietly being irreversible is the wrong thing to hand an ordinary user.
**Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponized in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
So if one of the two gets built here it is S/MIME, because it is the one that is *more* deployed in the places that pay for software: native in Outlook and Apple Mail, and routine in defense, healthcare, finance and government, where a CA issues and revokes certificates that an IT department can actually administer. The web of trust never became something anybody could run at scale.
Expect the asking to be far out of proportion to the using. A self-hosted webmail for Stalwart draws self-hosters, privacy-minded users and European SMEs, which is about the densest concentration of PGP users left alive — so this will be requested much more often than it would be used, and that is an argument for keeping it here, described honestly, rather than either building it on the strength of the requests or refusing it outright.
+53
View File
@@ -0,0 +1,53 @@
# Security Policy
## Supported Versions
ihasmail is under active development. Security fixes are applied to the latest release on the `main` branch. Older tags/releases are not guaranteed to receive backported fixes.
| Version | Supported |
| ------------- | ------------------ |
| `main` (latest) | :white_check_mark: |
| Older releases | :x: |
## Reporting a Vulnerability
**Please do not open a public GitHub issue for security vulnerabilities.** Public issues are visible to everyone, including potential attackers, before a fix is available.
Instead, report security issues privately by emailing:
**johnellisATlinuxDOTcom**
Please include as much of the following as you can:
- A description of the vulnerability and its potential impact
- Steps to reproduce, or a proof-of-concept
- The version/commit of ihasmail affected
- The version of Stalwart Mail Server you were testing against, if relevant
- Whether the issue is in ihasmail itself, in how it talks to Stalwart over JMAP, or in a dependency
### What to Expect
- **Acknowledgment:** You should receive a response within a few days confirming the report was received.
- **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on Stalwart's store over JMAP, some reports may need to be routed to or coordinated with the [Stalwart Mail Server](https://github.com/stalwartlabs/mail-server) project if the root cause lives there rather than in ihasmail's client code.
- **Fix & disclosure:** Once a fix is ready, a new release will be published. We'll coordinate with you on public disclosure timing and credit, if you'd like to be credited.
### Scope
In scope:
- Authentication and session handling in ihasmail
- Cross-site scripting (XSS), CSRF, or injection issues in the webmail UI
- Improper handling of JMAP responses that could lead to data leakage between accounts
- Dependency vulnerabilities that are actually exploitable in ihasmail's usage
Out of scope (please report upstream instead):
- Vulnerabilities in Stalwart Mail Server itself — report those to the [Stalwart project](https://github.com/stalwartlabs/mail-server)
- Vulnerabilities in third-party libraries with no demonstrated impact on ihasmail
- Issues requiring physical access to a user's device or an already-compromised Stalwart instance
## Disclosure Policy
We follow coordinated disclosure: please give us a reasonable window to investigate and release a fix before any public disclosure. In turn, we'll keep you updated on progress and won't leave you waiting indefinitely.
Thank you for helping keep ihasmail and its users safe.
View File
-9
View File
@@ -1,9 +0,0 @@
import os, secrets
APP_SECRET = os.getenv("APP_SECRET") or secrets.token_urlsafe(32)
COOKIE_NAME = os.getenv("COOKIE_NAME", "stalwart_webmail")
JMAP_BASE = os.getenv("JMAP_BASE", "https://mail.example.com/jmap")
CALDAV_BASE = os.getenv("CALDAV_BASE", "https://mail.example.com/caldav/")
WEBDAV_BASE = os.getenv("WEBDAV_BASE", "https://mail.example.com/webdav/")
TRUST_PROXY = os.getenv("TRUST_PROXY", "1") == "1"
UPSTREAM_TIMEOUT = float(os.getenv("UPSTREAM_TIMEOUT", "15"))
-41
View File
@@ -1,41 +0,0 @@
from typing import List, Dict, Any, Tuple, Optional
import httpx
from urllib.parse import urljoin
from . import config
DAV_PROPFIND = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>"""
async def propfind(ac: httpx.AsyncClient, base: str, path: Optional[str], auth: Tuple[str,str]) -> List[Dict[str, Any]]:
href = urljoin(base, path or "/")
r = await ac.request("PROPFIND", href, content=DAV_PROPFIND, headers={"Depth": "1"}, auth=auth)
if r.status_code not in (207, 200):
raise RuntimeError(f"WebDAV error {r.status_code}")
import xml.etree.ElementTree as ET
tree = ET.fromstring(r.text)
ns = {"d":"DAV:"}
items: List[Dict[str, Any]] = []
for resp in tree.findall("d:response", ns):
href_el = resp.find("d:href", ns)
prop = resp.find("d:propstat/d:prop", ns)
if href_el is None or prop is None:
continue
name = prop.find("d:displayname", ns)
cl = prop.find("d:getcontentlength", ns)
rtype = prop.find("d:resourcetype", ns)
is_collection = rtype is not None and rtype.find("d:collection", ns) is not None
items.append({
"href": href_el.text,
"name": (name.text if name is not None and name.text else href_el.text.rstrip("/").split("/")[-1] or "/"),
"type": "directory" if is_collection else "file",
"size": int(cl.text) if (cl is not None and cl.text and cl.text.isdigit()) else None
})
if items:
items = items[1:]
return items
-31
View File
@@ -1,31 +0,0 @@
from typing import Any, Dict, List, Tuple
import httpx
from . import config
def client() -> httpx.AsyncClient:
limits = httpx.Limits(max_connections=20, max_keepalive_connections=10)
return httpx.AsyncClient(timeout=config.UPSTREAM_TIMEOUT, limits=limits, trust_env=True)
async def get_session(ac: httpx.AsyncClient, base: str, username: str, password: str) -> Dict[str, Any]:
r = await ac.get(base, auth=(username, password))
if r.status_code == 401:
raise PermissionError("Invalid credentials")
r.raise_for_status()
return r.json()
async def call(ac: httpx.AsyncClient, api_url: str, auth: Tuple[str,str] | None, method_calls: List[list]) -> Dict[str, Any]:
payload = {
"using": [
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:contacts",
"urn:ietf:params:jmap:calendars"
],
"methodCalls": method_calls
}
kwargs: Dict[str, Any] = {"json": payload}
if auth:
kwargs["auth"] = auth
r = await ac.post(api_url, **kwargs)
r.raise_for_status()
return r.json()
-34
View File
@@ -1,34 +0,0 @@
import bleach
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.proxy_headers import ProxyHeadersMiddleware
from . import config
from .routes import auth, mail, contacts, calendar, webdav, sieve
app = FastAPI(title="Stalwart Webmail (Python)")
if config.TRUST_PROXY:
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
app.add_middleware(SessionMiddleware, secret_key=config.APP_SECRET, session_cookie=config.COOKIE_NAME, same_site="lax", https_only=True)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
@app.get("/", include_in_schema=False)
async def root(request: Request):
from fastapi.responses import RedirectResponse
return RedirectResponse("/mail" if request.session.get("user") else "/login")
# Routers
app.include_router(auth.router)
app.include_router(mail.router)
app.include_router(contacts.router)
app.include_router(calendar.router)
app.include_router(webdav.router)
app.include_router(sieve.router)
@app.get("/healthz", include_in_schema=False)
async def healthz():
return {"ok": True}
View File
-45
View File
@@ -1,45 +0,0 @@
from fastapi import APIRouter, Request, Form, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import PlainTextResponse
from .. import config, jmap
from fastapi.templating import Jinja2Templates
from jinja2 import FileSystemLoader, Environment, select_autoescape
import pathlib, base64, os
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def make_csrf(session: dict) -> str:
token = base64.urlsafe_b64encode(os.urandom(24)).decode()
session["csrf"] = token
return token
def check_csrf(session: dict, token: str):
if not token or token != session.get("csrf"):
raise HTTPException(status_code=400, detail="CSRF token invalid")
@router.get("/login", response_class=HTMLResponse)
async def login_form(request: Request):
csrf = make_csrf(request.session)
return templates.TemplateResponse("login.html", {"request": request, "csrf": csrf, "jmap_base": config.JMAP_BASE})
@router.post("/login")
async def login_submit(request: Request, username: str = Form(...), password: str = Form(...), jmap_base: str = Form(...), csrf: str = Form(...)):
check_csrf(request.session, csrf)
async with jmap.client() as ac:
try:
session = await jmap.get_session(ac, jmap_base, username, password)
except PermissionError:
raise HTTPException(status_code=401, detail="Invalid credentials")
api_url = session.get("apiUrl") or jmap_base
download_url = session.get("downloadUrl") or ""
primary = session.get("primaryAccounts") or {}
request.session["user"] = {"username": username, "jmap_base": jmap_base, "api_url": api_url, "auth": (username, password), "download_url": download_url, "primary": primary, "session": session}
return RedirectResponse("/mail", status_code=303)
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
-39
View File
@@ -1,39 +0,0 @@
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import pathlib, datetime
from .. import jmap
from ..utils import fmt_when
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def require_user(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401)
return user
@router.get("/calendar", response_class=HTMLResponse)
async def calendar(request: Request, user=Depends(require_user)):
async with jmap.client() as ac:
api = user["api_url"]
account_id = None
now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
until = now + datetime.timedelta(days=30)
res = await jmap.call(ac, api, tuple(user["auth"]), [
["CalendarEvent/query", {"accountId": account_id, "limit": 200, "sort":[{"property":"start","isAscending": True}]}, "q1"],
["CalendarEvent/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"CalendarEvent/query","path":"ids"}, "properties":["id","title","start","end","location"]}, "g1"]
])
events = []
for name, data, _ in res.get("methodResponses", []):
if name == "CalendarEvent/get":
for e in data.get("list", []):
try:
s = datetime.datetime.fromisoformat((e.get("start") or "").replace("Z","+00:00"))
if s < now - datetime.timedelta(days=1) or s > until:
continue
except Exception:
pass
events.append({"title": e.get("title") or "(no title)", "start": fmt_when(e.get("start")), "end": fmt_when(e.get("end")), "loc": e.get("location")})
return templates.TemplateResponse("calendar.html", {"request": request, "events": events, "user": user})
-35
View File
@@ -1,35 +0,0 @@
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import pathlib
from .. import jmap
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def require_user(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401)
return user
@router.get("/contacts", response_class=HTMLResponse)
async def contacts(request: Request, q: Optional[str] = None, user=Depends(require_user)):
async with jmap.client() as ac:
api = user["api_url"]
account_id = None
filter_cond = {"text": q} if q else {}
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Contact/query", {"accountId": account_id, "filter": filter_cond, "limit": 100}, "c1"],
["Contact/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Contact/query","path":"ids"}, "properties":["id","firstName","lastName","emails","company"]}, "c2"]
])
contacts = []
for name, data, _ in res.get("methodResponses", []):
if name == "Contact/get":
for c in data.get("list", []):
emails = [e.get("email","") for e in (c.get("emails") or [])]
contacts.append({"name": f"{c.get('firstName','')} {c.get('lastName','')}".strip() or (emails[0] if emails else ""),
"email": ", ".join(emails),
"org": c.get("company")})
return templates.TemplateResponse("contacts.html", {"request": request, "contacts": contacts, "q": q, "user": user})
-316
View File
@@ -1,316 +0,0 @@
import json
import io
import bleach
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException, Form, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
from fastapi.templating import Jinja2Templates
import pathlib
from .. import jmap
from ..utils import human_size, fmt_when
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def require_user(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401)
return user
def make_csrf(session: dict) -> str:
import os, base64
token = base64.urlsafe_b64encode(os.urandom(24)).decode()
session["csrf"] = token
return token
def check_csrf(session: dict, token: str):
if not token or token != session.get("csrf"):
raise HTTPException(status_code=400, detail="CSRF token invalid")
@router.get("/mail", response_class=HTMLResponse)
async def inbox(request: Request, q: Optional[str] = None, mailbox: Optional[str] = None, user=Depends(require_user)):
async with jmap.client() as ac:
api = user["api_url"]
primary = user.get("primary", {})
account_id = primary.get("urn:ietf:params:jmap:mail")
boxes, inbox_id = await get_mailboxes(ac, api, tuple(user["auth"]), account_id)
box_id = mailbox or inbox_id
filt = {"text": q} if q else ({"inMailbox": box_id} if box_id else {})
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Email/query", {"accountId": account_id, "filter": filt, "sort": [{"property":"receivedAt","isAscending": False}], "limit": 50}, "c1"],
["Email/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Email/query","path":"ids"}, "properties": ["id","subject","from","size","receivedAt"]}, "c2"]
])
emails = []
for name, data, _ in res.get("methodResponses", []):
if name == "Email/get":
for e in data.get("list", []):
from_str = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])])
emails.append({"id": e["id"], "subject": e.get("subject") or "(no subject)", "from": from_str, "when": fmt_when(e.get("receivedAt")), "size": human_size(e.get("size"))})
return templates.TemplateResponse("mail.html", {"request": request, "messages": emails, "q": q, "user": user, "mailboxes": boxes, "selected": box_id})
@router.get("/mail/{email_id}", response_class=HTMLResponse)
async def read_message(request: Request, email_id: str, user=Depends(require_user)):
async with jmap.client() as ac:
api = user["api_url"]
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Email/get", {"ids": [email_id], "properties": ["id","subject","from","to","receivedAt","size","keywords","preview","bodyStructure","htmlBody","textBody"]}, "c1"]
])
msg = {"id": email_id, "subject":"", "from":"", "to":[], "when":"", "textBody":"", "htmlBody":"", "attachments":[]}
bstruct = None
cid_map = {}
for name, data, _ in res.get("methodResponses", []):
if name == "Email/get":
lst = data.get("list", [])
if lst:
e = lst[0]
msg["subject"] = e.get("subject") or msg["subject"]
msg["from"] = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])]) or msg["from"]
msg["to"] = [a.get("email","") for a in (e.get("to") or [])] or msg["to"]
msg["when"] = fmt_when(e.get("receivedAt")) or msg["when"]
if "textBody" in e:
msg["textBody"] = e.get("textBody") or msg["textBody"]
if "htmlBody" in e:
raw_html = e.get("htmlBody")
if raw_html:
msg["htmlBody"] = bleach.clean(raw_html, tags=bleach.sanitizer.ALLOWED_TAGS.union({"p","span","div","br","hr","pre","code","blockquote","ul","ol","li","table","thead","tbody","tr","th","td","img","a","b","i","strong","em"}), attributes={"a":["href","title"],"img":["src","alt","title","width","height"]}, strip=True)
bstruct = bstruct or e.get("bodyStructure")
def walk_cid(bs):
if not isinstance(bs, dict): return
cid = bs.get("cid")
if cid and bs.get("blobId"):
cid_map[cid.strip("<>")] = {"blobId": bs["blobId"], "name": bs.get("name") or "inline"}
for p in bs.get("subParts", []) or []:
walk_cid(p)
if bstruct:
walk_cid(bstruct)
def walk_bs(bs, out):
if not isinstance(bs, dict): return
if bs.get("disposition") == "attachment":
out.append({"name": bs.get("name") or "attachment", "type": bs.get("type") or "application/octet-stream", "size": bs.get("size"), "blobId": bs.get("blobId")})
for p in bs.get("subParts", []) or []:
walk_bs(p, out)
att = []
walk_bs(bstruct, att)
msg["attachments"] = att
# Inline CID images via internal route
if msg.get("htmlBody") and cid_map:
import re as _re
def _repl(m):
cid = m.group(1)
return f'src="/mail/{email_id}/cid/{cid}"'
msg["htmlBody"] = _re.sub(r'src=\"cid:([^\"]+)\"', _repl, msg["htmlBody"]) # cid_rewrite
return templates.TemplateResponse("message.html", {"request": request, "msg": msg, "user": user})
@router.get("/compose", response_class=HTMLResponse)
async def compose_form(request: Request, user=Depends(require_user)):
csrf = make_csrf(request.session)
return templates.TemplateResponse("compose.html", {"request": request, "csrf": csrf, "user": user})
@router.post("/compose")
async def compose_send(request: Request, to: str = Form(...), subject: str = Form(""), body: str = Form(""), csrf: str = Form(...), action: str = Form("send"), files: list[UploadFile] = File(default=[]), user=Depends(require_user)):
check_csrf(request.session, csrf)
async with jmap.client() as ac:
api = user["api_url"]
primary = user.get("primary", {})
account_id = primary.get("urn:ietf:params:jmap:mail")
# Upload attachments if any
upload_url = user.get("upload_url")
blobs = []
form = await request.form()
for k, v in form.multi_items():
if k == 'preblob':
try:
b = json.loads(v)
if b.get('blobId'): blobs.append(b)
except Exception:
pass
if files:
for f in files:
data = await f.read()
if upload_url:
url = upload_url.replace("{accountId}", account_id or "")
ru = await ac.post(url, content=data, headers={"Content-Type": f.content_type or "application/octet-stream"}, auth=tuple(user["auth"]))
ru.raise_for_status()
up = ru.json()
blobs.append({"blobId": up.get("blobId"), "type": f.content_type or "application/octet-stream", "name": f.filename, "size": len(data)})
email_creation_id = "k1"
submission_creation_id = "k2"
create_email = {
"accountId": account_id,
"create": {
email_creation_id: {
"mailboxIds": {},
"from": [{"email": user["username"]}],
"to": [{"email": x.strip()} for x in to.split(",") if x.strip()],
"subject": subject,
"textBody": body,
"attachments": [{"blobId": b["blobId"], "type": b["type"], "name": b["name"]} for b in blobs]
}
}
}
# Move to Drafts if requested, else submit and move to Sent
special = await get_special_mailboxes(ac, api, tuple(user["auth"]), account_id)
sent_id = special.get("sent")
drafts_id = special.get("drafts")
calls = []
calls.append(["Email/set", create_email, "s1"])
if action == "draft":
if drafts_id:
calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {drafts_id: True}}}}, "sdraft"])
else:
calls.append(["EmailSubmission/set", {"accountId": account_id, "create": {submission_creation_id: {"emailId": {"resultOf":"s1","name":"Email/set","path": f"created/{email_creation_id}/id"}}}}, "s2"])
if sent_id:
calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {sent_id: True}}}}, "ssent"])
await jmap.call(ac, api, tuple(user["auth"]), calls)
return RedirectResponse("/mail", status_code=303)
async def get_mailboxes(ac, api, auth, account_id):
res = await jmap.call(ac, api, auth, [
["Mailbox/query", {"accountId": account_id, "sort":[{"property":"sortOrder","isAscending": True},{"property":"name","isAscending": True}], "limit": 200}, "q1"],
["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","name","role","totalEmails","unreadEmails"]}, "g1"]
])
boxes = []
inbox_id = None
for name, data, _ in res.get("methodResponses", []):
if name == "Mailbox/get":
for b in data.get("list", []):
boxes.append({"id": b["id"], "name": b.get("name",""), "role": b.get("role"), "total": b.get("totalEmails",0), "unread": b.get("unreadEmails",0)})
if b.get("role") == "inbox":
inbox_id = b["id"]
return boxes, inbox_id or (boxes[0]["id"] if boxes else None)
@router.get("/mail/{email_id}/attach/{index}")
async def download_attachment(request: Request, email_id: str, index: int, user=Depends(require_user)):
atts = request.query_params.get("atts")
# Re-fetch message to resolve bodyStructure (simple approach; could cache)
async with jmap.client() as ac:
api = user["api_url"]
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"]
])
bstruct = None
cid_map = {}
for name, data, _ in res.get("methodResponses", []):
if name == "Email/get":
lst = data.get("list", [])
if lst:
bstruct = lst[0].get("bodyStructure")
parts = []
def walk(bs, out):
if not isinstance(bs, dict): return
if bs.get("disposition") == "attachment":
out.append(bs)
for p in bs.get("subParts", []) or []:
walk(p, out)
walk(bstruct, parts)
if index < 0 or index >= len(parts):
raise HTTPException(status_code=404, detail="Attachment not found")
p = parts[index]
blob = p.get("blobId")
name = p.get("name") or "attachment"
ctype = p.get("type") or "application/octet-stream"
# Build download URL from session template
tmpl = user.get("download_url") or ""
primary = user.get("primary", {})
account_id = primary.get("urn:ietf:params:jmap:mail")
url = tmpl
if "{accountId}" in url:
url = url.replace("{accountId}", account_id or "")
if "{blobId}" in url:
url = url.replace("{blobId}", blob or "")
if "{name}" in url:
from urllib.parse import quote
url = url.replace("{name}", quote(name))
# Fallback naive pattern if template missing
if not url or "{" in url:
from urllib.parse import urljoin, quote
base = user.get("jmap_base")
url = urljoin(base, f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}")
async with jmap.client() as ac:
r = await ac.get(url, auth=tuple(user["auth"]))
r.raise_for_status()
return StreamingResponse(io.BytesIO(r.content), media_type=ctype, headers={"Content-Disposition": f'attachment; filename="{name}"'})
async def get_special_mailboxes(ac, api, auth, account_id):
res = await jmap.call(ac, api, auth, [
["Mailbox/query", {"accountId": account_id, "limit": 200}, "q1"],
["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","role","name"]}, "g1"]
])
sent_id = drafts_id = inbox_id = None
boxes = {}
for name, data, _ in res.get("methodResponses", []):
if name == "Mailbox/get":
for b in data.get("list", []):
boxes[b["id"]] = b
role = b.get("role")
if role == "sent": sent_id = b["id"]
if role == "drafts": drafts_id = b["id"]
if role == "inbox": inbox_id = b["id"]
return {"sent": sent_id, "drafts": drafts_id, "inbox": inbox_id, "all": boxes}
@router.get("/mail/{email_id}/cid/{cid}")
async def fetch_cid(request: Request, email_id: str, cid: str, user=Depends(require_user)):
# Walk bodyStructure to find matching cid, then download via downloadUrl
async with jmap.client() as ac:
api = user["api_url"]
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"]
])
bstruct = None
for name, data, _ in res.get("methodResponses", []):
if name == "Email/get":
lst = data.get("list", [])
if lst:
bstruct = lst[0].get("bodyStructure")
target = None
def walk(bs):
nonlocal target
if not isinstance(bs, dict) or target is not None: return
if bs.get("cid") and bs.get("cid").strip("<>") == cid:
target = bs
return
for p in bs.get("subParts", []) or []:
walk(p)
walk(bstruct)
if not target:
raise HTTPException(status_code=404, detail="Inline part not found")
blob = target.get("blobId")
ctype = target.get("type") or "application/octet-stream"
name = target.get("name") or "inline"
tmpl = user.get("download_url") or ""
primary = user.get("primary", {})
account_id = primary.get("urn:ietf:params:jmap:mail")
from urllib.parse import quote, urljoin
if tmpl and "{accountId}" in tmpl and "{blobId}" in tmpl:
url = tmpl.replace("{accountId}", account_id or "").replace("{blobId}", blob or "")
if "{name}" in url:
url = url.replace("{name}", quote(name))
else:
url = urljoin(user.get("jmap_base"), f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}")
async with jmap.client() as ac:
r = await ac.get(url, auth=tuple(user["auth"]))
r.raise_for_status()
return StreamingResponse(io.BytesIO(r.content), media_type=ctype)
@router.post("/upload")
async def upload_file(request: Request, file: UploadFile = File(...), user=Depends(require_user)):
async with jmap.client() as ac:
primary = user.get("primary", {})
account_id = user.get("active_account") or primary.get("urn:ietf:params:jmap:mail")
upload_url = user.get("upload_url")
if not upload_url or not account_id:
raise HTTPException(status_code=400, detail="Upload not available")
url = upload_url.replace("{accountId}", account_id)
data = await file.read()
r = await ac.post(url, content=data, headers={"Content-Type": file.content_type or "application/octet-stream"}, auth=tuple(user["auth"]))
r.raise_for_status()
up = r.json()
return JSONResponse({"blobId": up.get("blobId"), "type": file.content_type or "application/octet-stream", "name": file.filename, "size": len(data)})
-21
View File
@@ -1,21 +0,0 @@
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import pathlib
from .. import dav, jmap, config
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def require_user(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401)
return user
@router.get("/webdav", response_class=HTMLResponse)
async def webdav_browse(request: Request, path: Optional[str]=None, user=Depends(require_user)):
async with jmap.client() as ac:
items = await dav.propfind(ac, config.WEBDAV_BASE, path, tuple(user["auth"]))
return templates.TemplateResponse("webdav.html", {"request": request, "items": items, "base": config.WEBDAV_BASE, "user": user})
-30
View File
@@ -1,30 +0,0 @@
:root { color-scheme: light dark; --header-bg: #f6f7f9; --header-fg: #111; --card-bg: #fff; }
@media (prefers-color-scheme: dark) { :root { --header-bg: #0f172a; --header-fg: #e5e7eb; --card-bg: #0b1222; } }
body { margin:0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header, footer { padding: 10px 14px; border-bottom: 1px solid #4443; background: var(--header-bg); color: var(--header-fg); }
main { padding: 14px; max-width: 1100px; margin: 0 auto; }
nav a { margin-right: 12px; }
.btn { display:inline-block; padding:6px 10px; border:1px solid #6665; border-radius:8px; text-decoration:none; }
table { border-collapse: collapse; width: 100%; }
th, td { padding: 8px; border-bottom: 1px solid #6662; text-align: left; vertical-align: top; }
.muted { color: #888; }
input, textarea, select { padding:6px 8px; width:100%; box-sizing: border-box; }
form .row { display:grid; grid-template-columns: 160px 1fr; gap: 8px; align-items: center; margin-bottom:10px; }
.msg { cursor:pointer; }
.pill { display:inline-block; font-size:12px; padding:2px 6px; border:1px solid #6663; border-radius:999px; margin-right:6px;}
.nowrap { white-space: nowrap; }
.right { text-align:right; }
.toolbar { display:flex; gap:8px; align-items:center; margin:8px 0; }
.panel { border:1px solid #6663;padding:10px;border-radius:8px;margin:10px 0;white-space:pre-wrap }
#dropzone{padding:16px;border:2px dashed #6665;border-radius:8px;text-align:center;margin:10px 0}
.brand { display:flex; align-items:center; gap:10px; }
.brand .logo { height:28px; vertical-align:middle; }
.brand-link { text-decoration:none; color:inherit; }
header nav { margin-top:6px; }
.badge { display:inline-block; padding:0 6px; border-radius:10px; font-size:12px; background:#6662; margin-left:6px; }
.card{background:var(--card-bg); border:1px solid #6663; border-radius:12px; padding:18px; box-shadow:0 2px 6px #0001;}
.center{display:grid; place-items:center; min-height:60vh;}
.logo-lg{height:64px;}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

-37
View File
@@ -1,37 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title or "ihasmail" }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://unpkg.com;">
<link rel="icon" href="/static/img/logo.png">
<link rel="preconnect" href="https://unpkg.com">
<script defer src="https://unpkg.com/[email protected]"></script>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<div class="brand">
<a href="/" class="brand-link"><img src="/static/img/logo.png" alt="ihasmail" class="logo"> <strong>ihasmail</strong></a>
</div>
<nav>
{% if user %}
<span class="muted">Signed in as {{ user.get("username") }}</span>
<a class="btn" href="/mail">Inbox</a>
<a class="btn" href="/compose">Compose</a>
<a class="btn" href="/calendar">Calendar</a>
<a class="btn" href="/contacts">Contacts</a>
<a class="btn" href="/webdav">WebDAV</a>
<a class="btn" href="/logout">Logout</a>
{% else %}
<a class="btn" href="/login">Login</a>
{% endif %}
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="muted">ihasmail • JMAP • Sieve • DAV • FastAPI • reverse-proxy ready</footer>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Calendar (JMAP & CalDAV)</h1>
<p class="muted">Listing upcoming events via JMAP. CalDAV endpoints available for DAV clients.</p>
<table>
<tr><th>When</th><th>Summary</th><th>Where</th></tr>
{% for e in events %}
<tr>
<td class="nowrap">{{ e.start }} {{ e.end }}</td>
<td>{{ e.title }}</td>
<td>{{ e.loc or "" }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
-11
View File
@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Compose</h1>
<form method="post" action="/compose">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="row"><label>To</label><input name="to" required></div>
<div class="row"><label>Subject</label><input name="subject"></div>
<div class="row"><label>Body</label><textarea name="body" rows="14"></textarea></div>
<button class="btn">Send</button>
</form>
{% endblock %}
-19
View File
@@ -1,19 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Contacts (Directory via JMAP)</h1>
<div class="toolbar">
<form>
<input name="q" value="{{ q or '' }}" placeholder="Search name/email…">
</form>
</div>
<table>
<tr><th>Name</th><th>Email</th><th>Org</th></tr>
{% for c in contacts %}
<tr>
<td>{{ c.name }}</td>
<td>{{ c.email }}</td>
<td>{{ c.org or "" }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
-24
View File
@@ -1,24 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="center"><div class="card" style="min-width:320px; max-width:420px;">
<div style="text-align:center;margin-bottom:8px"><img class="logo-lg" src="/static/img/logo.png" alt="ihasmail"></div>
<h2 style="text-align:center;margin-top:0">Sign in</h2>
<form method="post" action="/login">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="row">
<label>Username</label>
<input name="username" autocomplete="username" required>
</div>
<div class="row">
<label>Password</label>
<input type="password" name="password" autocomplete="current-password" required>
</div>
<div class="row">
<label>JMAP Base</label>
<input name="jmap_base" value="{{ jmap_base }}">
</div>
<button class="btn" type="submit">Sign in</button>
</form>
</div></div>
<p class="muted">Credentials are sent to your JMAP server to obtain a session/auth token; they are not stored on the server.</p>
{% endblock %}
-26
View File
@@ -1,26 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Inbox</h1>
<div class="toolbar">
<form method="get" action="/mail">
<select name="mailbox" onchange="this.form.submit()">
{% for b in mailboxes %}
<option value="{{ b.id }}" {% if b.id == selected %}selected{% endif %}>{{ b.name }}{% if b.unread %} ({{ b.unread }}){% endif %}</option>
{% endfor %}
</select>
<input name="q" placeholder="Search (from, subject, text…)" value="{{ q or '' }}">
</form>
<a class="btn" href="/compose">Compose</a>
</div>
<table>
<tr><th class="nowrap">When</th><th>From</th><th>Subject</th><th class="right">Size</th></tr>
{% for m in messages %}
<tr class="msg" onclick="location.href='/mail/{{ m.id }}'">
<td class="nowrap">{{ m.when }}</td>
<td>{{ m.from }}</td>
<td>{{ m.subject }}</td>
<td class="right">{{ m.size }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
-25
View File
@@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ msg.subject or "(no subject)" }}</h1>
<p><span class="pill">From</span> {{ msg.from }} <span class="pill">To</span> {{ msg.to|join(", ") }}</p>
<p class="muted">{{ msg.when }}</p>
{% if msg.htmlBody %}
<div class="panel">{{ (msg.htmlBody | safe) }}</div>
{% elif msg.textBody %}
<div class="panel">{{ msg.textBody }}</div>
{% else %}
<div class="panel muted">(no body)</div>
{% endif %}
<div class="toolbar">
<a class="btn" href="/compose?reply={{ msg.id }}">Reply</a>
<a class="btn" href="/compose?forward={{ msg.id }}">Forward</a>
</div>
{% if msg.attachments %}
<h3>Attachments</h3>
<ul>
{% for a in msg.attachments %}
<li>{{ a.name }} ({{ a.type }}, {{ a.size }} bytes)</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
-15
View File
@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>WebDAV</h1>
<p class="muted">Browsing {{ base }}</p>
<table>
<tr><th>Name</th><th>Type</th><th class="right">Size</th></tr>
{% for i in items %}
<tr>
<td>{{ i.name }}</td>
<td>{{ i.type }}</td>
<td class="right">{% if i.size is not none %}{{ i.size }}{% endif %}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
-19
View File
@@ -1,19 +0,0 @@
import datetime
def human_size(n: int | None) -> str:
if n is None: return ""
units = ["B","KB","MB","GB","TB","PB"]
i = 0
x = float(n)
while x >= 1024 and i < len(units)-1:
x /= 1024.0
i += 1
return f"{x:.0f} {units[i]}"
def fmt_when(iso: str | None) -> str:
if not iso: return ""
try:
dt = datetime.datetime.fromisoformat(iso.replace("Z","+00:00")).astimezone()
return dt.strftime("%Y-%m-%d %H:%M")
except Exception:
return iso or ""
-6
View File
@@ -1,6 +0,0 @@
apiVersion: v2
name: ihasmail
description: ihasmail — JMAP webmail for Stalwart (FastAPI)
type: application
version: 0.1.0
appVersion: "0.2.0"
-7
View File
@@ -1,7 +0,0 @@
Thanks for installing ihasmail!
Get the service URL by running these commands:
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "ihasmail.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo http://$SERVICE_IP:{{ .Values.service.port }}/
If using Ingress and DNS, browse to the configured host (e.g., https://ihasmail.example.com).
-20
View File
@@ -1,20 +0,0 @@
{{- define "ihasmail.name" -}}
{{- .Chart.Name -}}
{{- end -}}
{{- define "ihasmail.fullname" -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "ihasmail.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/name: {{ include "ihasmail.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "ihasmail.selectorLabels" -}}
app.kubernetes.io/name: {{ include "ihasmail.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
-60
View File
@@ -1,60 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "ihasmail.fullname" . }}
labels:
{{- include "ihasmail.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "ihasmail.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "ihasmail.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: {{ include "ihasmail.fullname" . }}-secret
key: APP_SECRET
- name: JMAP_BASE
value: {{ .Values.env.JMAP_BASE | quote }}
- name: CALDAV_BASE
value: {{ .Values.env.CALDAV_BASE | quote }}
- name: WEBDAV_BASE
value: {{ .Values.env.WEBDAV_BASE | quote }}
- name: COOKIE_NAME
value: {{ .Values.env.COOKIE_NAME | quote }}
- name: TRUST_PROXY
value: {{ .Values.env.TRUST_PROXY | quote }}
- name: UPSTREAM_TIMEOUT
value: {{ .Values.env.UPSTREAM_TIMEOUT | quote }}
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 10
periodSeconds: 20
---
apiVersion: v1
kind: Secret
metadata:
name: {{ include "ihasmail.fullname" . }}-secret
type: Opaque
stringData:
APP_SECRET: {{ .Values.env.APP_SECRET | quote }}
-30
View File
@@ -1,30 +0,0 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "ihasmail.fullname" . }}
{{- if .Values.ingress.className }}
annotations:
kubernetes.io/ingress.class: {{ .Values.ingress.className }}
{{- end }}
spec:
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "ihasmail.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
{{- end }}
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "ihasmail.fullname" . }}
labels:
{{- include "ihasmail.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 8000
protocol: TCP
name: http
selector:
{{- include "ihasmail.selectorLabels" . | nindent 4 }}
-32
View File
@@ -1,32 +0,0 @@
image:
repository: ghcr.io/your-org/ihasmail
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8000
ingress:
enabled: false
className: ""
hosts:
- host: ihasmail.example.com
paths:
- path: /
pathType: Prefix
tls: []
env:
APP_SECRET: "CHANGE_ME"
JMAP_BASE: "https://mail.example.com/jmap"
CALDAV_BASE: "https://mail.example.com/caldav/"
WEBDAV_BASE: "https://mail.example.com/webdav/"
COOKIE_NAME: "ihasmail"
TRUST_PROXY: "1"
UPSTREAM_TIMEOUT: "15"
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}
+274
View File
@@ -0,0 +1,274 @@
#!/bin/bash
# Redeploy ihasmail on a single-host Docker setup, from a git checkout.
#
# Copy it, or run it as-is and set the variables below in the environment.
# Nothing here is specific to any one host: the defaults describe the shape of
# a deployment rather than anyone's particular one.
#
# Usage: ./deploy.sh [git-ref] [-y|--yes] [-n|--dry-run]
#
# Three guards stand between a careless run and production:
#
# .deploy-hold commits that must not reach prod yet, one per line. If the
# target contains one that is not already deployed, the deploy
# is refused outright -- `--yes` does not override it. Clearing
# a hold means deleting its line, which is a deliberate edit.
#
# confirmation anything introducing new commits is listed first and has to
# be confirmed. Over SSH, where there is no terminal to answer
# on, that means passing --yes: a bare `deploy.sh` cannot ship
# whatever main happens to have picked up since the last
# release.
#
# --dry-run checks the hold list, says what it would deploy, and stops
# before building or touching the container. It does not ask
# for confirmation: there is nothing to agree to when nothing
# changes, and needing a terminal would make it useless over
# SSH -- which is where wanting to look before leaping is most
# likely.
#
# The container is replaced rather than restarted, because the image is rebuilt
# from the new checkout. Data lives in a named volume and survives that; the
# environment file is never read here, only handed to Docker.
set -euo pipefail
# --- what to deploy, and where ----------------------------------------------
# The checkout to deploy from. It must be a git clone: the version number is
# read from its history (see scripts/version.mjs).
APP="${IHASMAIL_APP:-$HOME/apps/ihasmail}"
# Environment file passed to the container. Keep it outside the repo's tracked
# files -- it holds APP_SECRET and the upstream URL. Never read by this script.
ENVF="${IHASMAIL_ENV:-$APP/.env.production}"
# Commits held back from production, one per line; blank or missing is fine.
HOLD="${IHASMAIL_HOLD:-$APP/.deploy-hold}"
# Container name, and where to publish it. The default binds to loopback only,
# for a reverse proxy in front (see Caddyfile.example / nginx.example.conf).
NAME="${IHASMAIL_NAME:-ihasmail}"
BIND="${IHASMAIL_BIND:-127.0.0.1:8090}"
# Named volume for /data (sessions). Unused when running immutably.
VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
# Run the container immutably: read-only root filesystem, no volume, sessions
# held in memory only. See "Running immutably" in the README. The server is told
# the same thing through IMMUTABLE=1 and checks it, so a half-applied switch --
# the flag without the read-only filesystem, or a SESSION_FILE still pointing
# somewhere -- refuses to start here instead of looking fine until the next
# redeploy signs everyone out.
#
# It defaults to on, and the reason is what happens when it does not. Forgetting
# the variable used to hand back a writable container with a volume mounted --
# quietly, and then report healthy. Nothing in the output said the immutability
# had gone; `docker inspect` was the only place it showed. So the safe posture
# is what you get by default, and giving it up is the half that has to be
# deliberate, which is the way round these two should always have been.
#
# The standing cost is that sessions do not outlive a deploy, because there is
# nowhere left to keep them. Going back is this variable and nothing else:
#
# IHASMAIL_IMMUTABLE=0 ./ihasmail-deploy.sh --yes
#
# The named volume is never touched either way, so whatever was in it when the
# switch was thrown is still there to come back to.
IMMUTABLE="${IHASMAIL_IMMUTABLE:-1}"
# Image repository. Each build is tagged with its version as well, so an
# earlier one can be run again without rebuilding it.
IMAGE_REPO="${IHASMAIL_IMAGE:-ihasmail}"
# How long to wait for the new container to report healthy, in seconds.
HEALTH_TIMEOUT="${IHASMAIL_HEALTH_TIMEOUT:-30}"
# How many past versions to keep as images, for rolling back to. Each is around
# 650 MB, and a deploy adds one, so left alone they accumulate a gigabyte every
# couple of releases -- and `docker image prune` will not touch them, because
# they are tagged. 0 keeps every version.
KEEP_VERSIONS="${IHASMAIL_KEEP_VERSIONS:-3}"
# --- run from a copy, if this script lives in the checkout it resets ---------
# `git reset --hard` below rewrites the working tree, and this script may be
# part of it. Bash does not read a script all at once -- it reads as it goes,
# by byte offset -- so a file replaced underneath it makes the shell stop
# wherever it had reached. Silently, and with exit status 0: a deploy that
# stopped halfway would report success. Re-exec from a copy outside the tree so
# the file being run cannot change while it runs.
SELF="$(readlink -f "$0")"
APP_REAL="$(readlink -f "$APP" 2>/dev/null || printf '%s' "$APP")"
if [ -z "${IHASMAIL_REEXEC:-}" ] && [ "${SELF#"$APP_REAL"/}" != "$SELF" ]; then
COPY="$(mktemp "${TMPDIR:-/tmp}/ihasmail-deploy.XXXXXX")"
cat "$SELF" > "$COPY"
chmod +x "$COPY"
IHASMAIL_REEXEC=1 exec "$COPY" "$@"
fi
# The copy has served its purpose once we exit; the shell has finished reading
# it by then.
if [ -n "${IHASMAIL_REEXEC:-}" ]; then
trap 'rm -f "$SELF"' EXIT
fi
REF=""
ASSUME_YES=0
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
-y|--yes) ASSUME_YES=1 ;;
-n|--dry-run) DRY_RUN=1 ;;
-h|--help) awk 'NR > 1 { if (/^#/) print; else exit }' "$0"; exit 0 ;;
-*) echo "unknown option: $arg" >&2; exit 2 ;;
*)
if [ -n "$REF" ]; then echo "give at most one git-ref (got '$REF' and '$arg')" >&2; exit 2; fi
REF="$arg" ;;
esac
done
REF="${REF:-origin/main}"
cd "$APP"
git fetch --quiet origin
if ! TARGET=$(git rev-parse --verify --quiet "${REF}^{commit}"); then
echo "!! no such commit: $REF" >&2
exit 2
fi
CURRENT=$(git rev-parse --verify HEAD)
# --- guard 1: commits held back from production -----------------------------
if [ -f "$HOLD" ]; then
blocked=""
while IFS= read -r line || [ -n "$line" ]; do
line="${line%%#*}"
line="$(printf '%s' "$line" | tr -d '[:space:]')"
[ -z "$line" ] && continue
if ! held=$(git rev-parse --verify --quiet "${line}^{commit}"); then
echo " (hold list names '$line', which this checkout does not know -- ignoring)" >&2
continue
fi
# Only a problem if the target carries it and production does not already.
if git merge-base --is-ancestor "$held" "$TARGET" && ! git merge-base --is-ancestor "$held" "$CURRENT"; then
blocked="${blocked} $(git log --oneline -1 "$held")"$'\n'
fi
done < "$HOLD"
if [ -n "$blocked" ]; then
echo "!! refusing to deploy $REF: it contains commits held back from production:" >&2
printf '%s' "$blocked" >&2
echo " listed in $HOLD -- delete the line to clear the hold, or deploy a ref without it." >&2
exit 1
fi
fi
# --- guard 2: say what is being introduced, and get a yes --------------------
NEW=$(git log --oneline "$CURRENT..$TARGET")
if [ -n "$NEW" ]; then
echo "==> $(git log --oneline -1 "$CURRENT") -> $(git log --oneline -1 "$TARGET")"
echo "==> introduces:"
printf '%s\n' "$NEW" | sed 's/^/ /'
else
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
fi
# A dry run has now said everything it has to say, so it stops here -- before
# the confirmation rather than after it. Asking whether to go ahead with
# something that is not going to happen is noise at a terminal; over SSH it was
# worse, because the refusal came out *instead of* the report above and a dry
# run could not be used from another machine at all. Which is the machine you
# are most likely to be on when you want one.
if [ "$DRY_RUN" -eq 1 ]; then
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
exit 0
fi
if [ -n "$NEW" ] && [ "$ASSUME_YES" -ne 1 ]; then
if [ -t 0 ]; then
read -r -p "deploy these to production? [y/N] " reply
case "$reply" in
y|Y|yes|YES) ;;
*) echo "aborted."; exit 1 ;;
esac
else
echo "!! refusing: this introduces new commits and there is no terminal to confirm on." >&2
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
exit 1
fi
fi
git reset --hard --quiet "$TARGET"
# The version is worked out here, from the checkout, because the image build
# cannot: .dockerignore keeps .git out of the build context. Without this the
# build falls back to the base version in package.json and every deployment
# reports the same number -- see "Version numbers" in the README.
# Drop the oldest versioned images, keeping the newest KEEP_VERSIONS of them.
#
# Only ever runs after the new container reports healthy, so a rollback target
# is never removed while the thing replacing it is still unproven. The image in
# use is excluded outright rather than relied on to sort newest -- docker
# refuses to remove an image a container is using, but being refused is not the
# same as not having tried.
prune_old_images() {
[ "$KEEP_VERSIONS" -gt 0 ] || return 0
local in_use stale
in_use="$(docker inspect "$NAME" --format '{{.Config.Image}}' 2>/dev/null || true)"
# Newest first, tags only, skipping the moving ":current" pointer.
stale="$(docker images "$IMAGE_REPO" --format '{{.Repository}}:{{.Tag}}\t{{.CreatedAt}}' \
| grep -v ":current" \
| sort -k2 -r \
| cut -f1 \
| grep -vxF "$in_use" \
| tail -n +"$((KEEP_VERSIONS + 1))")"
[ -n "$stale" ] || return 0
echo "==> removing $(printf '%s\n' "$stale" | wc -l) old image(s), keeping the newest $KEEP_VERSIONS"
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
}
# The version is the same sum scripts/version.mjs does -- the commit's own
# date, plus the pull request it arrived through or its short SHA -- done here
# in shell because a host that only runs containers has git and docker and no
# node. Given IHASMAIL_VERSION, use it as given, as the script would.
version_from_git() {
local date subject sha y m d
date="$(git show -s --format=%cs HEAD)"
subject="$(git show -s --format=%s HEAD)"
sha="$(git rev-parse --short HEAD)"
IFS=- read -r y m d <<<"$date"
if [[ "$subject" =~ ^Merge\ pull\ request\ \#([0-9]+) ]]; then
printf '%d.%d.%d+pr%s\n' "$((10#$y))" "$((10#$m))" "$((10#$d))" "${BASH_REMATCH[1]}"
else
printf '%d.%d.%d+g%s\n' "$((10#$y))" "$((10#$m))" "$((10#$d))" "$sha"
fi
}
VERSION="${IHASMAIL_VERSION:-$(version_from_git)}"
# A Docker tag may not contain "+", and every version has one now:
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
# request. The image is tagged with the "+" turned into "-"; what the build is
# *told* it is keeps the real form, so About and /api/health still report it
# correctly.
TAG="${VERSION//+/-}"
echo "==> building $(git log --oneline -1) as v$VERSION"
docker build \
--build-arg IHASMAIL_VERSION="$VERSION" \
-t "$IMAGE_REPO:$TAG" \
-t "$IMAGE_REPO:current" \
.
RUN_ARGS=(-d --name "$NAME" --restart unless-stopped -p "$BIND:8080" --env-file "$ENVF")
if [ "$IMMUTABLE" = "1" ]; then
# -e wins over --env-file, so this clears a SESSION_FILE set there or baked
# into the image, rather than needing the environment file edited to match.
RUN_ARGS+=(--read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE=)
echo "==> restarting container -- immutable: read-only, no volume, sessions in memory"
echo " (everyone signed in is signed out; IHASMAIL_IMMUTABLE=0 puts it back)"
else
RUN_ARGS+=(-v "$VOLUME:/data")
echo "==> restarting container"
fi
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run "${RUN_ARGS[@]}" "$IMAGE_REPO:$TAG" >/dev/null
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
if health=$(curl -sf "http://$BIND/api/health"); then
echo "==> healthy: $health"
prune_old_images
exit 0
fi
sleep 1
done
echo "!! did not become healthy after ${HEALTH_TIMEOUT}s; logs:" >&2
docker logs "$NAME" 2>&1 | tail -20 >&2
echo "!! the previous image is still tagged, if you need it back:" >&2
docker images "$IMAGE_REPO" --format ' {{.Repository}}:{{.Tag}} {{.CreatedSince}}' | head -5 >&2
exit 1
+34 -7
View File
@@ -1,11 +1,38 @@
services:
ihasmail:
build: .
image: ihasmail:latest
env_file: .env
build:
context: .
args:
# Passed to the build as well as the run because the web bundle writes
# its own asset URLs: a build that does not know the prefix produces an
# app that cannot load itself under one. Empty is the domain root.
BASE_PATH: ${BASE_PATH:-}
image: ihasmail:2
restart: unless-stopped
networks: [edge]
# Loopback only: ihasmail expects a TLS reverse proxy in front of it. On
# every interface the app is reachable over plain HTTP, passwords and all,
# and with TRUST_PROXY any machine on a private network can set its own
# X-Forwarded-For. A proxy running in Docker can reach the service by name
# on the compose network and needs no published port at all.
ports:
- "127.0.0.1:8080:8000"
networks:
edge: {}
- "127.0.0.1:8080:8080"
# The app needs no privileges and writes only to /data and /tmp.
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail}
BASE_PATH: ${BASE_PATH:-}
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
TRUST_PROXY: "1"
IMAGE_PROXY: "1"
volumes:
- ihasmail-data:/data
volumes:
ihasmail-data:
+44
View File
@@ -0,0 +1,44 @@
# Example nginx location block for ihasmail behind TLS termination.
server {
listen 443 ssl http2;
server_name mail.example.com;
# ssl_certificate ...; ssl_certificate_key ...;
client_max_body_size 60m;
# Compression. The bundle is the bulk of first load -- about 933 KB
# uncompressed against 311 KB gzipped -- and nginx passes through anything
# the upstream already encoded rather than re-encoding it, so this is
# correct whether or not ihasmail compresses on its own.
#
# text/event-stream is deliberately absent from gzip_types: the push stream
# must not be compressed or buffered, which is also why proxy_buffering is
# off below.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
# text/javascript is listed explicitly: ihasmail serves scripts with that
# type rather than application/javascript, so a conventional gzip_types
# list compresses the stylesheet and leaves the largest asset alone.
gzip_types
application/javascript
application/json
application/manifest+json
image/svg+xml
text/css
text/javascript
text/plain;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Server-Sent Events (push notifications)
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
+3122
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "ihasmail",
"version": "0.0.0",
"private": true,
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
"license": "AGPL-3.0-or-later",
"type": "module",
"workspaces": [
"server",
"web"
],
"engines": {
"node": ">=20.19"
},
"scripts": {
"dev": "concurrently -n server,web -c blue,magenta \"npm run dev -w server\" \"npm run dev -w web\"",
"build": "npm run build -w web && npm run build -w server",
"start": "node server/dist/index.js",
"typecheck": "npm run typecheck -w web && npm run typecheck -w server",
"test": "npm run test -w web && npm run test -w server",
"lint": "npm run typecheck",
"mock": "npm run mock -w server",
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"i18n:coverage": "node scripts/i18n-coverage.mjs",
"i18n:check": "node scripts/i18n-catalog-check.mjs --check && node scripts/i18n-literals.mjs --check",
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
},
"devDependencies": {
"concurrently": "^10.0.5",
"typescript": "^7.0.2",
"typescript-ast": "npm:typescript@^5.9.3"
}
}
-30
View File
@@ -1,30 +0,0 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ihasmail"
version = "0.2.0"
description = "ihasmail — JMAP webmail for Stalwart (FastAPI, HTMX/Jinja)"
authors = [{name = "John Coffey", email = "[email protected]"}]
readme = "README.md"
requires-python = ">=3.10"
license = {text = "GPL-3.0-or-later"}
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.30",
"httpx>=0.27",
"jinja2>=3.1",
"bleach>=6.1",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8.2",
"anyio>=4.4",
"httpx>=0.27",
]
[tool.pytest.ini_options]
addopts = "-q"
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+4
View File
@@ -0,0 +1,4 @@
/** Types for `basePath.mjs`, which is plain JS so both packages can import it. */
export function normalizeBasePath(value: string | undefined | null): string;
export function baseUrlOf(basePath: string | undefined | null): string;
export function stripBasePath(basePath: string | undefined | null, pathname: string): string | null;
+70
View File
@@ -0,0 +1,70 @@
/**
* The subpath ihasmail is mounted at, from `BASE_PATH`.
*
* Plain JS, and here rather than in either package, because both halves of the
* app have to agree on the answer: `web/vite.config.ts` bakes it into the built
* asset URLs and `server/src/config.ts` reads it again to decide where the
* routes live. Two implementations of "what does /mail/ mean" is exactly the
* bug where the server serves an app whose own script tags point somewhere
* else, and the page comes up blank with no clue why.
*
* The canonical form is a leading slash and no trailing one -- `/mail` -- with
* the empty string for the root. Empty is the ordinary case and it is chosen
* so that the concatenation `${base}/api/health` is right without a branch:
* anything with a trailing slash would need one, and every caller that forgot
* would produce `//api/health`, which browsers read as a *protocol-relative
* URL* and send to a host called `api`. Getting that wrong once, quietly, in
* one call site is worse than the small awkwardness of an empty string.
*/
/**
* Reduce whatever the operator wrote to the canonical form.
*
* Accepts `/mail`, `mail`, `/mail/`, `mail/`, `//mail//`, an empty string and
* undefined, because the variable is typed by a human into a compose file or a
* `docker run` line and every one of those is a reasonable thing to write.
* Being strict here would mean an instance that refuses to start over a
* trailing slash, which teaches nobody anything.
*
* A value of `/` means the root and is returned as empty, since `/` and `""`
* describe the same mount and only one of them can be the canonical one.
*/
export function normalizeBasePath(value) {
if (typeof value !== "string") return "";
// Collapse repeated separators before trimming: `//mail//` is a typo, not a
// path with empty segments in it, and `path.posix.normalize` is not
// available to the browser bundle that also uses this.
const trimmed = value.trim().replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
if (!trimmed) return "";
return `/${trimmed}`;
}
/**
* The same value as a directory URL -- `/` or `/mail/`.
*
* This is the form Vite's `base` and the PWA scope want, both of which are
* about "the directory the app lives in" rather than a path to join onto.
*/
export function baseUrlOf(basePath) {
return `${normalizeBasePath(basePath)}/`;
}
/**
* Whether `pathname` falls inside the mount, and what is left of it if so.
*
* Returns null for anything outside, so a caller can 404 rather than guess.
* The bare mount with no trailing slash -- a request for `/mail` -- yields
* `/`, because that is the app's own index and typing the prefix without the
* slash is how people reach it.
*
* The comparison is deliberately not `startsWith(base)`: that would let
* `/mailbox` in under a `/mail` mount and serve it the app shell, which is
* both wrong and a small open door for a neighboring site on the same host.
*/
export function stripBasePath(basePath, pathname) {
const base = normalizeBasePath(basePath);
if (!base) return pathname;
if (pathname === base) return "/";
if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length);
return null;
}
+407
View File
@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""
Generate the palette CSS blocks in web/src/styles/app.css.
Every color here comes from the palette's own project (all MIT); the values
are recorded in .palette-sources/palettes-upstream.md. What this script adds is
the *derivation*: ihasmail needs thirty-odd tokens and these projects publish
between twelve and twenty, so the tiers in between are computed rather than
guessed, and every text color is then checked against the surface it sits on.
The check is the reason this is a script and not a hand-written block. ihasmail
claims WCAG AA, and several of these palettes do not meet it as published --
Dracula's comment gray on its own background is about 3.0:1, well under the 4.5
that normal text needs. Lifting those tiers by eye is how a claim quietly stops
being true; here it is arithmetic, and the script fails loudly if a token it
emitted would not pass.
Run: python3 scripts/build-palettes.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CSS = ROOT / "web/src/styles/app.css"
BEGIN = "/* === generated palettes: begin === */"
END = "/* === generated palettes: end === */"
# ---------------------------------------------------------------- color maths
def parse(hex_: str) -> tuple[float, float, float]:
h = hex_.lstrip("#")
return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
def to_hex(rgb: tuple[float, float, float]) -> str:
return "#" + "".join(f"{max(0, min(255, round(c * 255))):02x}" for c in rgb)
def _lin(c: float) -> float:
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def luminance(hex_: str) -> float:
r, g, b = (_lin(c) for c in parse(hex_))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast(a: str, b: str) -> float:
la, lb = luminance(a), luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
def mix(a: str, b: str, t: float) -> str:
ca, cb = parse(a), parse(b)
return to_hex(tuple(ca[i] + (cb[i] - ca[i]) * t for i in range(3)))
def rgba(hex_: str, alpha: float) -> str:
r, g, b = (round(c * 255) for c in parse(hex_))
return f"rgba({r}, {g}, {b}, {alpha})"
def toward_contrast(color: str, bg: str, target: float, dark_ui: bool) -> str:
"""Nudge `color` away from `bg` until it clears `target`.
Toward white on a dark background and toward black on a light one, so a
lifted tier keeps its hue instead of washing out to gray.
"""
if contrast(color, bg) >= target:
return color
anchor = "#ffffff" if dark_ui else "#000000"
best = color
for i in range(1, 101):
candidate = mix(color, anchor, i / 100)
best = candidate
if contrast(candidate, bg) >= target:
return candidate
return best
# ------------------------------------------------------------------- palettes
# Roles as each project publishes them. Nothing here is invented; see
# .palette-sources/palettes-upstream.md for where each value came from.
# ihasmail's own palette has a hand-written dark block further up the file --
# it is the identity this project is painted in, and regenerating it would
# quietly move colors nobody asked to move. Only its light half is derived
# here, which is why it appears in LIGHT_ONLY.
LIGHT_ONLY = {"ihasmail"}
SOURCES = {
"ihasmail": {
# Daylight over the same teal-navy: the dark palette's background
# becomes the text, so the two halves are recognisably one palette read
# from either end. The cat is still orange, so the star still is.
"light": dict(
bg="#f4f9f9", elev="#ffffff", sunken="#e7f1f2", line="#cfe2e4",
fg="#0d2430", muted="#4a6b74", accent="#46cac3", link="#0e7490",
danger="#dc2626", warn="#b45309", success="#15803d", star="#f9a34b",
q1="#0e7490", q2="#15803d", q3="#7c3aed",
),
"dark": {}, # see LIGHT_ONLY
},
"dracula": {
"dark": dict(
bg="#282a36", elev="#2f3140", sunken="#21222c", line="#44475a",
fg="#f8f8f2", muted="#6272a4", accent="#bd93f9", link="#8be9fd",
danger="#ff5555", warn="#ffb86c", success="#50fa7b", star="#f1fa8c",
q1="#8be9fd", q2="#50fa7b", q3="#ff79c6",
),
"light": dict( # Alucard
bg="#fffbeb", elev="#ffffff", sunken="#f6f1de", line="#cfcfde",
fg="#1f1f1f", muted="#6c664b", accent="#644ac9", link="#036a96",
danger="#cb3a2a", warn="#a34d14", success="#14710a", star="#846e15",
q1="#036a96", q2="#14710a", q3="#a3144d",
),
},
"gruvbox": {
"dark": dict(
bg="#282828", elev="#32302f", sunken="#1d2021", line="#504945",
fg="#ebdbb2", muted="#a89984", accent="#83a598", link="#8ec07c",
danger="#fb4934", warn="#fe8019", success="#b8bb26", star="#fabd2f",
q1="#83a598", q2="#b8bb26", q3="#d3869b",
),
"light": dict(
bg="#fbf1c7", elev="#f9f5d7", sunken="#f2e5bc", line="#d5c4a1",
fg="#3c3836", muted="#7c6f64", accent="#076678", link="#427b58",
danger="#9d0006", warn="#af3a03", success="#79740e", star="#b57614",
q1="#076678", q2="#79740e", q3="#8f3f71",
),
},
"rose-pine": {
"dark": dict( # main
bg="#191724", elev="#1f1d2e", sunken="#14121f", line="#26233a",
fg="#e0def4", muted="#908caa", accent="#c4a7e7", link="#9ccfd8",
danger="#eb6f92", warn="#f6c177", success="#31748f", star="#f6c177",
q1="#9ccfd8", q2="#31748f", q3="#c4a7e7",
),
"light": dict( # dawn
bg="#faf4ed", elev="#fffaf3", sunken="#f2e9e1", line="#dfd9d2",
fg="#464261", muted="#797593", accent="#907aa9", link="#286983",
danger="#b4637a", warn="#ea9d34", success="#56949f", star="#ea9d34",
q1="#286983", q2="#56949f", q3="#907aa9",
),
},
"tokyo-night": {
"dark": dict( # night
bg="#1a1b26", elev="#1f2130", sunken="#16161e", line="#363b54",
fg="#c0caf5", muted="#a9b1d6", accent="#7aa2f7", link="#7dcfff",
danger="#f7768e", warn="#e0af68", success="#9ece6a", star="#e0af68",
q1="#7dcfff", q2="#9ece6a", q3="#bb9af7",
),
"light": dict( # day
bg="#e6e7ed", elev="#f2f3f7", sunken="#d6d8df", line="#c1c2c7",
fg="#343b59", muted="#484c61", accent="#2959aa", link="#006c86",
danger="#8c4351", warn="#8f5e15", success="#385f0d", star="#8f5e15",
q1="#006c86", q2="#385f0d", q3="#65359d",
),
},
"catppuccin": {
"dark": dict( # Mocha
bg="#1e1e2e", elev="#313244", sunken="#181825", line="#45475a",
fg="#cdd6f4", muted="#a6adc8", accent="#cba6f7", link="#89b4fa",
danger="#f38ba8", warn="#fab387", success="#a6e3a1", star="#f9e2af",
q1="#89b4fa", q2="#a6e3a1", q3="#f5c2e7",
),
"light": dict( # Latte
bg="#e6e9ef", elev="#eff1f5", sunken="#dce0e8", line="#ccd0da",
fg="#4c4f69", muted="#6c6f85", accent="#8839ef", link="#1e66f5",
danger="#d20f39", warn="#fe640b", success="#40a02b", star="#df8e1d",
q1="#1e66f5", q2="#40a02b", q3="#ea76cb",
),
},
"solarized": {
"dark": dict(
bg="#002b36", elev="#073642", sunken="#001f28", line="#0d4552",
fg="#839496", muted="#586e75", accent="#268bd2", link="#2aa198",
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
q1="#2aa198", q2="#859900", q3="#6c71c4",
),
"light": dict(
bg="#fdf6e3", elev="#fffdf6", sunken="#eee8d5", line="#e6dfc8",
fg="#657b83", muted="#93a1a1", accent="#268bd2", link="#2aa198",
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
q1="#2aa198", q2="#859900", q3="#6c71c4",
),
},
"ayu": {
"dark": dict(
bg="#0d1017", elev="#10141c", sunken="#070a0f", line="#1b1f29",
fg="#bfbdb6", muted="#5a6378", accent="#e6b450", link="#59c2ff",
danger="#f07178", warn="#ff8f40", success="#aad94c", star="#ffb454",
q1="#39bae6", q2="#aad94c", q3="#d2a6ff",
),
"light": dict(
bg="#f8f9fa", elev="#fcfcfc", sunken="#ebeef0", line="#dfe2e5",
fg="#5c6166", muted="#828e9f", accent="#f29718", link="#22a4e6",
danger="#f07171", warn="#fa8532", success="#86b300", star="#eba400",
q1="#55b4d4", q2="#86b300", q3="#a37acc",
),
},
"kanagawa": {
"dark": dict( # Wave
bg="#1f1f28", elev="#2a2a37", sunken="#16161d", line="#363646",
fg="#dcd7ba", muted="#727169", accent="#7e9cd8", link="#7fb4ca",
danger="#e82424", warn="#ff9e3b", success="#98bb6c", star="#e6c384",
q1="#7fb4ca", q2="#98bb6c", q3="#d27e99",
),
"light": dict( # Lotus
bg="#e5ddb0", elev="#f2ecbc", sunken="#dcd5ac", line="#d5cea3",
fg="#545464", muted="#716e61", accent="#624c83", link="#4d699b",
danger="#c84053", warn="#cc6d00", success="#6f894e", star="#77713f",
q1="#4d699b", q2="#6f894e", q3="#b35b79",
),
},
"everforest": {
"dark": dict( # medium
bg="#2d353b", elev="#343f44", sunken="#232a2e", line="#475258",
fg="#d3c6aa", muted="#859289", accent="#a7c080", link="#7fbbb3",
danger="#e67e80", warn="#e69875", success="#a7c080", star="#dbbc7f",
q1="#7fbbb3", q2="#a7c080", q3="#d699b6",
),
"light": dict( # medium
bg="#efebd4", elev="#fdf6e3", sunken="#e6e2cc", line="#bdc3af",
fg="#5c6a72", muted="#939f91", accent="#8da101", link="#3a94c5",
danger="#f85552", warn="#f57d26", success="#8da101", star="#dfa000",
q1="#3a94c5", q2="#8da101", q3="#df69ba",
),
},
"primer": {
"dark": dict(
bg="#0d1117", elev="#151b23", sunken="#010409", line="#3d444d",
fg="#f0f6fc", muted="#9198a1", accent="#58a6ff", link="#79c0ff",
danger="#ff7b72", warn="#e3b341", success="#3fb950", star="#d29922",
q1="#79c0ff", q2="#56d364", q3="#d2a8ff",
),
"light": dict(
bg="#f6f8fa", elev="#ffffff", sunken="#eff2f5", line="#d1d9e0",
fg="#25292e", muted="#59636e", accent="#0969da", link="#0550ae",
danger="#cf222e", warn="#9a6700", success="#1a7f37", star="#bf8700",
q1="#0550ae", q2="#116329", q3="#8250df",
),
},
}
# What each token has to clear, and against which surface. Normal text is 4.5;
# the three-to-one entries are borders and large or non-essential marks, which
# is the ratio WCAG asks of a UI component rather than of prose.
TEXT_ON_BG = {"fg": 7.0, "muted": 4.5, "faint": 4.5, "link": 4.5, "danger": 4.5, "warn": 4.5, "success": 4.5}
UI_ON_BG = {"accent": 3.0, "border-strong": 3.0, "star": 3.0}
def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], list[str]]:
dark = mode == "dark"
bg, fg = src["bg"], src["fg"]
notes: list[str] = []
def lift(name: str, color: str, target: float) -> str:
out = toward_contrast(color, bg, target, dark)
if out != color:
notes.append(f"{name} {color} -> {out} ({contrast(color, bg):.2f} -> {contrast(out, bg):.2f})")
return out
# Body text is lifted like every other text tone rather than exempted.
# Most of these palettes publish a body color around 4.5:1 -- their own
# target -- and ihasmail asks 7:1 of the text a reader looks at all day.
# Rejecting a palette over that would have cost five of the six added in
# 2026-09; nudging the published color along its own hue costs nothing a
# reader can name, and the shift is recorded in the header of the
# generated block like every other one.
fg = lift("fg", fg, TEXT_ON_BG["fg"])
muted = lift("muted", src["muted"], TEXT_ON_BG["muted"])
# Between muted and the background, but still readable: this is timestamps
# and counts, which are small and still prose.
faint = lift("faint", mix(muted, bg, 0.30), TEXT_ON_BG["faint"])
link = lift("link", src["link"], TEXT_ON_BG["link"])
danger = lift("danger", src["danger"], TEXT_ON_BG["danger"])
warn = lift("warn", src["warn"], TEXT_ON_BG["warn"])
success = lift("success", src["success"], TEXT_ON_BG["success"])
accent = lift("accent", src["accent"], UI_ON_BG["accent"])
star = lift("star", src["star"], UI_ON_BG["star"])
border_strong = lift("border-strong", mix(src["line"], fg, 0.15), UI_ON_BG["border-strong"])
accent_soft = rgba(accent, 0.16) if dark else mix(accent, bg, 0.86)
accent_soft_bg = mix(accent, bg, 0.84) if dark else mix(accent, bg, 0.86)
accent_soft_fg = toward_contrast(accent, accent_soft_bg, 4.5, dark)
accent_fg = "#ffffff" if contrast("#ffffff", accent) >= contrast(bg, accent) else bg
tokens = {
"--bg": bg,
"--bg-elev": src["elev"],
"--bg-sunken": src["sunken"],
"--bg-hover": rgba(fg, 0.06),
"--bg-active": rgba(fg, 0.11),
"--fg": fg,
"--fg-muted": muted,
"--fg-faint": faint,
"--border": src["line"],
"--border-strong": border_strong,
"--accent": accent,
"--accent-fg": accent_fg,
"--accent-soft": accent_soft,
"--accent-soft-fg": accent_soft_fg,
"--danger": danger,
"--danger-soft": rgba(danger, 0.15),
"--warn": warn,
"--warn-soft": rgba(warn, 0.15),
"--success": success,
"--success-soft": rgba(success, 0.15),
"--link": link,
"--unread-bg": src["elev"] if dark else "#ffffff",
"--read-bg": src["sunken"] if dark else mix(bg, fg, 0.03),
"--selected-bg": rgba(accent, 0.18) if dark else mix(accent, bg, 0.86),
"--focus-ring": f"0 0 0 3px {rgba(accent, 0.40)}",
"--star": star,
"--q1": lift("q1", src["q1"], 4.5),
"--q2": lift("q2", src["q2"], 4.5),
"--q3": lift("q3", src["q3"], 4.5),
"--scrollbar": rgba(muted, 0.35),
"color-scheme": "dark" if dark else "light",
}
if dark:
tokens["--shadow-1"] = "0 1px 2px rgba(0, 0, 0, 0.45)"
tokens["--shadow-2"] = "0 8px 24px rgba(0, 0, 0, 0.55)"
tokens["--shadow-3"] = "0 22px 60px -28px rgba(0, 0, 0, 0.75)"
return tokens, notes
def verify(pid: str, mode: str, tokens: dict[str, str]) -> list[str]:
"""Fail loudly rather than emit a palette that breaks the AA claim."""
bg = tokens["--bg"]
bad = []
for token, target in [
("--fg", 7.0), ("--fg-muted", 4.5), ("--fg-faint", 4.5), ("--link", 4.5),
("--danger", 4.5), ("--warn", 4.5), ("--success", 4.5),
("--accent", 3.0), ("--border-strong", 3.0), ("--star", 3.0),
("--q1", 4.5), ("--q2", 4.5), ("--q3", 4.5),
]:
ratio = contrast(tokens[token], bg)
if ratio + 1e-9 < target:
bad.append(f"{pid}/{mode} {token} {tokens[token]} on {bg}: {ratio:.2f} < {target}")
ratio = contrast(tokens["--accent-soft-fg"], tokens["--bg-elev"])
return bad
def css_for(pid: str, mode: str, tokens: dict[str, str]) -> str:
sel = f':root[data-palette="{pid}"]' if mode == "light" else f':root[data-theme="dark"][data-palette="{pid}"]'
lines = [f"{sel} {{"]
for k, v in tokens.items():
lines.append(f" {k}: {v};")
lines.append("}")
return "\n".join(lines)
def main() -> int:
blocks: list[str] = [
BEGIN,
"/*",
" * Written by scripts/build-palettes.py -- edit the sources there, not here.",
" *",
" * Every color is from the palette's own project (all MIT); the published",
" * values are recorded in .palette-sources/palettes-upstream.md. The tiers",
" * between them are derived, and every text color is checked against the",
" * surface it sits on: 4.5:1 for prose, 3:1 for borders and marks. Several",
" * of these palettes do not meet that as published -- Dracula's comment gray",
" * is about 3.0:1 on its own background -- so those tiers are lifted, which",
" * is why this is arithmetic rather than a hand-written block.",
" */",
]
problems: list[str] = []
for pid, modes in SOURCES.items():
for mode in ("light", "dark"):
if pid in LIGHT_ONLY and mode == "dark":
continue
tokens, notes = build(pid, mode, modes[mode])
problems += verify(pid, mode, tokens)
if notes:
blocks.append(f"/* {pid} ({mode}) lifted for contrast: " + "; ".join(notes) + " */")
blocks.append(css_for(pid, mode, tokens))
blocks.append(END)
generated = "\n\n".join(blocks) + "\n"
if problems:
print("Contrast check failed:", file=sys.stderr)
for p in problems:
print(" " + p, file=sys.stderr)
return 1
css = CSS.read_text(encoding="utf-8")
if BEGIN in css:
css = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?", generated, css, flags=re.S)
else:
css = css.rstrip() + "\n\n" + generated
CSS.write_text(css, encoding="utf-8")
print(f"Wrote {len(SOURCES) * 2} palette blocks to {CSS.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
/*
* Check a catalog against the strings the code actually asks for.
*
* Two failures, and only one of them is visible without this.
*
* A *missing* key renders English. That is the designed fallback and shows up
* as an untranslated word on screen, which somebody will eventually notice.
*
* A *stale* key -- one whose English no longer exists, usually because it was
* mistyped when the catalog was written -- is silent. The translation sits
* in the file looking correct, is never looked up, and the app renders English
* for ever. Nothing warns, because a catalog is only ever read by key.
*/
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, globSync } from "node:fs";
/*
* Two sets, because there are two questions and they need different nets.
*
* `wanted` is what a catalog *owes*: the strings that actually reach t(),
* tc() or plural(). Coverage is measured against it, so it has to stay strict
* -- widening it would count every CSS class and JMAP method name as an
* untranslated string.
*
* `seen` is every string literal in the source, and answers only "is this
* catalog key still written down anywhere". Stale detection needs the wide
* net: a key reaches t() as a variable often enough that a strict set reports
* mostly false alarms.
*/
const wanted = new Set();
const seen = new Set();
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const visit = (n) => {
/*
* Anything held in a constant and translated where it renders -- t(s.label),
* t(b.description), t(group) -- reaches t() as a variable, so there is no
* literal at the call site and every one of them looked "stale".
*
* This used to chase the shapes one at a time: a `label:` property, then an
* object named *_LABELS. It still cried wolf, because the shapes kept
* coming -- `description:` and `group:` on keyboard bindings, the calendar's
* view names, the read-receipt refusals, the palette names. 41 reported,
* 10 of them real. A report that is three-quarters false is one nobody acts
* on, which is how these sat unread long enough to be worth a commit of
* their own.
*
* So: any string literal anywhere in the source counts as a use. That
* under-reports -- a literal that exists but is never passed to t() will not
* be flagged -- and that is the right way round. A missed stale key costs a
* line of dead translation; a false one costs the credibility of the whole
* check, and then every real finding with it.
*/
if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) seen.add(n.text);
if (ts.isJsxText(n)) { const text = n.text.trim(); if (text) seen.add(text); }
/*
* A `label:` in a constant is still a string somebody has to translate --
* it reaches t() one render later -- so it stays part of what a catalog
* owes, and out of coverage it would flatter the number.
*/
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /_LABELS?$/.test(n.name.text)) {
const walk = (x) => { if (ts.isStringLiteral(x)) wanted.add(x.text); ts.forEachChild(x, walk); };
if (n.initializer) walk(n.initializer);
}
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
const fn = n.expression.text, a0 = n.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
// tc(context, source) keys the catalog on both, joined by the same
// control character tc() uses. Without this the contextual entries all
// looked stale, which is the checker's own false alarm rather than a
// catalog problem.
if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) {
// Only the contextual key is required. The plain one is tc()'s
// fallback, not a second obligation -- asking for both would report
// work that does not exist.
wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
seen.add(`${a0.text}\u0004${n.arguments[1].text}`);
}
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
for (const p of n.arguments[1].properties) {
if (ts.isPropertyAssignment(p) && p.name.getText(src) === "other" && ts.isStringLiteral(p.initializer)) wanted.add(p.initializer.text);
}
}
}
ts.forEachChild(n, visit);
};
visit(src);
}
/*
* A catalog and a picker entry are two halves of one thing, and either half
* alone is dead weight. A catalog with no entry in UI_LANGUAGES never
* reaches a reader -- it builds, it passes every test, and the language simply
* is not offered. That happened to Dutch: the entry was added by a text
* replacement anchored on a line that did not exist on that branch, so it was
* a silent no-op and nothing anywhere complained.
*/
const languagesSrc = readFileSync("web/src/lib/languages.ts", "utf8");
const registered = new Set([...languagesSrc.matchAll(/tag:\s*"([\w-]+)"/g)].map((m) => m[1]));
const catalogs = new Set(globSync("web/src/locales/*.ts").map((f) => f.split("/").pop().replace(".ts", "")));
let failed = false;
for (const tag of catalogs) {
if (!registered.has(tag)) {
failed = true;
console.log(`!! ${tag}.ts exists but is not in UI_LANGUAGES — the language is never offered\n`);
}
}
for (const tag of registered) {
if (tag !== "en" && !catalogs.has(tag)) {
failed = true;
console.log(`!! UI_LANGUAGES offers ${tag} but there is no ${tag}.ts — it would fall back to English\n`);
}
}
for (const file of globSync("web/src/locales/*.ts")) {
const tag = file.split("/").pop().replace(".ts", "");
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const have = new Set();
const visit = (n) => {
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.name)) have.add(n.name.text);
ts.forEachChild(n, visit);
};
visit(src);
const stale = [...have].filter((k) => !seen.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
const missing = [...wanted].filter((k) => !have.has(k));
const pct = Math.round(((wanted.size - missing.length) / wanted.size) * 100);
console.log(`${tag}: ${wanted.size - missing.length}/${wanted.size} translated (${pct}%), ${missing.length} falling back to English`);
if (stale.length) {
failed = true;
console.log(`\n ${stale.length} STALE key(s) — translated but never looked up, so they do nothing:`);
for (const k of stale) console.log(` ${JSON.stringify(k)}`);
}
if (process.argv.includes("--missing")) {
console.log(`\n missing:`);
for (const k of missing) console.log(` ${JSON.stringify(k)}`);
}
}
if (failed && process.argv.includes("--check")) process.exit(1);
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/*
* How much of the interface is extracted, and what is left.
*
* Extraction is ~1,000 strings across ~56 files, which is far too many to
* carry in anyone's head or to eyeball in review. This counts what is still
* hardcoded so the work can be done a file at a time and the remainder is
* always a number rather than a feeling.
*
* It is a progress report, not a gate: run it, do a file, run it again. It
* exits non-zero only with --check, so CI can be told to fail on regressions
* later, once the number is low enough for that to mean something.
*/
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, globSync } from "node:fs";
/** Attributes a person reads. `className` and `key` are not among them. */
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "message", "description"]);
/* Text that is not prose: punctuation, separators, and the single glyphs used
as dividers. Counting these as untranslated would put a floor under the
number that no amount of work could reach. */
const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
/*
* Text that is deliberately not translated is not "remaining work". Counting
* it put a floor under the number that no amount of effort could reach -- the
* report sat at 21 with only 6 real items left, which makes the number
* something to argue with rather than act on. Same rule the codemod uses.
*/
const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
const optedOut = (node, src) => {
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
return Boolean(opening?.attributes.properties.some((a) =>
ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
};
const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__"));
const rows = [];
let done = 0, todo = 0;
for (const file of files) {
const text = readFileSync(file, "utf8");
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let left = 0;
const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length;
const visit = (node) => {
if ((ts.isJsxElement(node) && CODE_TAGS.has(node.openingElement.tagName.getText(src).toLowerCase())) || optedOut(node, src)) return;
if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++;
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
// The same prose test the text nodes get. Without it, placeholders that
// are format examples -- "123456" for a one-time code, "+1 555 0100" for
// a phone -- counted as untranslated work forever.
if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text.trim())) left++;
}
ts.forEachChild(node, visit);
};
visit(src);
done += wrapped;
todo += left;
if (left) rows.push([file.replace("web/src/", ""), left, wrapped]);
}
rows.sort((a, b) => b[1] - a[1]);
const pct = done + todo === 0 ? 100 : Math.round((done / (done + todo)) * 100);
console.log(`i18n extraction: ${done} wrapped, ${todo} remaining across ${rows.length} files (${pct}%)\n`);
for (const [f, left, w] of rows.slice(0, Number(process.argv.find((a) => a.startsWith("--top="))?.slice(6) ?? 15))) {
console.log(` ${String(left).padStart(4)} left${w ? `, ${w} done` : " "} ${f}`);
}
if (rows.length > 15 && !process.argv.includes("--all")) console.log(`\n …and ${rows.length - 15} more (--all, or --top=N)`);
if (process.argv.includes("--check") && todo > 0) process.exit(1);
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
/*
* Wrap the strings a codemod can safely wrap, and report the ones it cannot.
*
* Roughly 1,000 strings is too many to hand-edit without introducing typos
* into the copy itself, and a parser does not get bored. But it must not be
* trusted with everything: text that is split around an interpolation arrives
* as separate fragments, and wrapping each fragment on its own produces
* "Move " and " messages", which no translator can do anything with. Those are
* left alone and listed, because they need a sentence built by hand.
*
* node scripts/i18n-extract.mjs <file...> rewrite in place
* node scripts/i18n-extract.mjs --dry <file...>
*/
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, writeFileSync } from "node:fs";
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "description"]);
const NOT_PROSE = /^[\s·—–\-:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
/*
* Elements whose text is not prose however much it looks like it. `label:name`
* inside <code> is a search operator: translating it breaks the thing it
* documents. The first run of this wrapped exactly that, which is why the list
* exists.
*/
const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
/*
* JSX decodes HTML entities in text; a JS string literal does not. Moving
* `Language &amp; region` into t("...") without decoding renders the entity
* literally on screen -- which the first run of this did, and which no
* typecheck or test noticed. It took looking at the page.
*/
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", mdash: "—", ndash: "", hellip: "…", times: "×", middot: "·" };
const decode = (s) => s.replace(/&(\w+);/g, (whole, name) => ENTITIES[name] ?? whole)
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
const tagOf = (node, src) => (ts.isJsxElement(node) ? node.openingElement.tagName.getText(src) : "");
const optedOut = (node, src) => {
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
return Boolean(opening?.attributes.properties.some((a) =>
ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
};
const dry = process.argv.includes("--dry");
const files = process.argv.slice(2).filter((a) => !a.startsWith("--"));
let wrapped = 0;
const skipped = [];
for (const file of files) {
const text = readFileSync(file, "utf8");
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
/*
* `t` is a natural name for a callback parameter, and several files already
* use it -- `(t: SieveTest) => ...`, `.map((t) => ...)`. An import called
* `t` is shadowed inside those callbacks, silently where the local happens
* to be callable. So the name is checked first and aliased where it is
* taken, per file, rather than assumed to be free.
*/
let bound = false;
const scan = (n) => {
if ((ts.isParameter(n) || ts.isVariableDeclaration(n) || ts.isBindingElement(n)) && n.name && ts.isIdentifier(n.name) && n.name.text === "t") bound = true;
ts.forEachChild(n, scan);
};
scan(src);
const T = bound ? "translate" : "t";
/** [start, end, replacement] — applied back-to-front so offsets hold. */
const edits = [];
const visit = (node) => {
if (ts.isJsxElement(node) || ts.isJsxFragment(node)) {
if (CODE_TAGS.has(tagOf(node, src).toLowerCase()) || optedOut(node, src)) return; // and not its children
const kids = node.children;
const meaningful = kids.filter((c) => !(ts.isJsxText(c) && !c.text.trim()));
for (const c of kids) {
if (!ts.isJsxText(c)) continue;
const raw = c.text;
const body = raw.trim();
if (body.length < 2 || NOT_PROSE.test(body)) continue;
/*
* "Split around an interpolation" is the dangerous case, and it is
* narrower than "has siblings". `<Plus /> New rule` is a phrase next
* to an icon: wrapping it alone is correct, and refusing it left a
* third of the remaining work to be done by hand for no reason.
* `Your script “{name}” was written by hand` is the real thing --
* a sibling that renders text, so the fragments are not sentences.
*/
const textSibling = kids.some((k) => k !== c && ts.isJsxExpression(k) && k.expression && !(() => {
let jsx = false;
const w = (n) => { if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) { jsx = true; return; } ts.forEachChild(n, w); };
w(k.expression);
return jsx;
})());
if (textSibling) {
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
skipped.push({ file, line: line + 1, why: "text split around an expression", text: body.slice(0, 52) });
continue;
}
if (decode(body).includes('"')) {
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
skipped.push({ file, line: line + 1, why: "contains a quote", text: body.slice(0, 52) });
continue;
}
// Keep the original leading/trailing whitespace: JSX collapses it, and
// reflowing here would change the rendered spacing.
const lead = raw.slice(0, raw.indexOf(body[0]));
const tail = raw.slice(raw.lastIndexOf(body[body.length - 1]) + 1);
edits.push([c.getStart(src), c.getEnd(), `${lead}{${T}("${decode(body.replace(/\s+/g, " "))}")}${tail}`]);
wrapped++;
}
}
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text)) {
if (decode(lit.text).includes('"')) {
const { line } = src.getLineAndCharacterOfPosition(lit.getStart(src));
skipped.push({ file, line: line + 1, why: "contains a quote", text: lit.text.slice(0, 52) });
} else {
edits.push([i.getStart(src), i.getEnd(), `{${T}("${decode(lit.text)}")}`]);
wrapped++;
}
}
}
ts.forEachChild(node, visit);
};
visit(src);
if (!edits.length) continue;
let out = text;
for (const [start, end, rep] of edits.sort((a, b) => b[0] - a[0])) out = out.slice(0, start) + rep + out.slice(end);
if (!/from "@\/lib\/i18n"/.test(out)) {
const lastImport = [...out.matchAll(/^import .*?;$/gm)].pop();
const decl = bound ? 'import { t as translate } from "@/lib/i18n";' : 'import { t } from "@/lib/i18n";';
if (lastImport) out = out.slice(0, lastImport.index + lastImport[0].length) + "\n" + decl + out.slice(lastImport.index + lastImport[0].length);
}
if (!dry) writeFileSync(file, out);
}
console.log(`${dry ? "would wrap" : "wrapped"} ${wrapped} strings across ${files.length} files`);
if (skipped.length) {
console.log(`\n${skipped.length} left for a person:`);
for (const s of skipped) console.log(` ${s.file.replace("web/src/", "")}:${s.line} (${s.why}) ${s.text}`);
}
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
/*
* User-visible English the extraction pass cannot see.
*
* `i18n:coverage` reads JSX text, and reported 100% while the calendar's
* Day/Week/Month/Agenda buttons rendered English in all nine languages. It was
* not wrong about what it measured -- those labels were never JSX text. They
* were built from an expression, and so was every toast argument, every
* `confirmDialog({ title })`, and every `Could not save: ${err}`.
*
* A string reaches a reader translated if either is true:
*
* 1. it is wrapped where it is written -- t(), tc(), tNode(), plural()
* 2. it is a catalog key, translated somewhere else
*
* The second case is a real convention here, not a loophole: constant tables
* hold English and the render site calls `t(s.label)`. What this refuses is a
* string that is neither -- one no catalog has a key for, which therefore
* cannot be translated at all, however many languages ship.
*/
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, globSync } from "node:fs";
/* Where a string literal in this position is shown to somebody. */
const UI_PROPS = new Set([
"title", "message", "label", "confirmLabel", "cancelLabel", "ariaLabel",
"placeholder", "hint", "occurrenceLabel", "occurrenceHint", "seriesLabel", "seriesHint",
]);
/*
* A JSX attribute is shown whether it lands on an element or on a component:
* `<MenuItem label="Collapse all">` renders its label as given, exactly as
* `<button title="…">` does. Checking only the DOM spellings let every
* component prop through, so the props are checked here too.
*/
const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt", ...UI_PROPS]);
const TOASTS = new Set(["error", "success", "info", "show"]);
const WRAPPERS = ["t", "tc", "tNode", "translate", "plural"];
const EQUALITY = new Set([
ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken,
ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken,
]);
/*
* Product names, example addresses and URL scaffolding. These reach t() and
* are deliberately absent from every catalog -- translating "ihasmail" or
* "[email protected]" would be a bug, not a feature -- so they would otherwise
* be reported for ever.
*/
const NEVER_TRANSLATED = new Set([
"ihasmail", "ihasmail.org", "ihasmail test", "Stalwart", "Stalwart Mail Server",
"AGPL-3.0-or-later · {source}", "•••", "https://", "https://…",
"https://meet.example.com/…", "[email protected]", "[email protected]",
"[email protected]", "List-Id", "X-Spam-Status",
]);
/* Prose, not an identifier: opens like a sentence, and has lower-case letters. */
const looksLikeUi = (s) =>
/[a-z]/.test(s) && /^[A-Z(“]/.test(s) && (/\s/.test(s) || /[.?!…]$/.test(s));
const keys = new Set();
{
const src = readFileSync("web/src/locales/de.ts", "utf8");
for (const m of src.matchAll(/^\s{4}"((?:[^"\\]|\\.)*)":/gm)) keys.add(m[1].replace("\\u0004", ""));
}
const found = [];
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
/*
* `strict` withdraws the catalog-key exemption. It exists for English held
* in a constant and translated where it renders; a literal written straight
* into a JSX attribute has no later render site to be translated at -- no
* component here passes its props through t() -- so being a key only means
* a translation exists that this string never reaches.
*/
const report = (node, text, strict = false) => {
if (!looksLikeUi(text) || (!strict && keys.has(text)) || NEVER_TRANSLATED.has(text)) return;
const { line } = src.getLineAndCharacterOfPosition(node.getStart(src));
found.push({ file, line: line + 1, text });
};
/*
* Literals that are not text on their way to a reader.
*
* Two kinds. One is already inside t("...") -- walking into the call would
* report the very string that proves it is handled. The other is an operand
* of an equality test: `rule.name === "New filter"` compares against a
* sentinel stored in the Sieve script, and translating it would not change
* what a reader sees, it would break the comparison.
*/
const exempt = new Set();
const mark = (n) => {
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && WRAPPERS.includes(n.expression.text)) {
const walk = (x) => { if (ts.isStringLiteral(x)) exempt.add(x); ts.forEachChild(x, walk); };
for (const a of n.arguments) walk(a);
}
if (ts.isBinaryExpression(n) && EQUALITY.has(n.operatorToken.kind)) {
for (const side of [n.left, n.right]) if (ts.isStringLiteral(side)) exempt.add(side);
}
ts.forEachChild(n, mark);
};
mark(src);
const wrapped = exempt;
/*
* English assembled around values: `aria-label={`Remove ${email}`}`.
*
* The literal cannot be a catalog key as written, so whether it is a key is
* not asked. Neither is looksLikeUi, which reads the opening of a sentence:
* `${name} — shared by ${owner}` opens with a value and its words come after.
* Any run of letters between the values counts. The only template literals
* that reach a UI attribute and are not prose are pure punctuation around
* values, like `${name} (${size})`, and those have none.
*/
const reportTemplate = (x) => {
const parts = ts.isNoSubstitutionTemplateLiteral(x) ? [x.text] : [x.head.text, ...x.templateSpans.map((s) => s.literal.text)];
if (!/[A-Za-z]{2,}/.test(parts.join(""))) return;
const { line } = src.getLineAndCharacterOfPosition(x.getStart(src));
found.push({ file, line: line + 1, text: parts.join("{}") });
};
const isTemplate = (x) => ts.isTemplateExpression(x) || ts.isNoSubstitutionTemplateLiteral(x);
const visit = (n) => {
if (ts.isPropertyAssignment(n) && UI_PROPS.has(n.name.getText(src).replace(/['"]/g, ""))) {
if (ts.isStringLiteral(n.initializer) && !wrapped.has(n.initializer)) report(n.initializer, n.initializer.text);
if (isTemplate(n.initializer)) reportTemplate(n.initializer);
}
if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) {
const walk = (x) => {
if (isTemplate(x)) reportTemplate(x);
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text, true);
if (!ts.isCallExpression(x)) ts.forEachChild(x, walk);
};
walk(n.initializer);
}
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)
&& n.expression.expression.getText(src) === "toast" && TOASTS.has(n.expression.name.text)) {
const a0 = n.arguments[0];
if (a0 && ts.isStringLiteral(a0) && !wrapped.has(a0)) report(a0, a0.text);
/* A template literal cannot be a catalog key at all, so it is always a find. */
if (a0 && ts.isTemplateExpression(a0)) report(a0, a0.head.text + "{}");
}
ts.forEachChild(n, visit);
};
visit(src);
}
if (!found.length) {
console.log("i18n literals: none -- every user-visible string is wrapped or has a catalog key");
process.exit(0);
}
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalog can translate:\n`);
for (const f of found) console.log(` ${f.file}:${f.line}\n ${JSON.stringify(f.text)}`);
console.log("\nWrap them in t() / plural(), or -- for a label held in a constant and");
console.log("translated where it renders -- make sure the English is a catalog key.");
process.exit(process.argv.includes("--check") ? 1 : 0);
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
/*
* Every source string a catalog needs, straight out of the calls.
*
* The English text is the key, so the catalog's keys are not a list somebody
* maintains -- they are whatever t(), tNode() and plural() are actually asked
* for. Reading them from the code means a catalog can never drift out of
* step with the app in the one direction that matters: a key that no longer
* exists is dead weight, but a call with no key is an untranslated string
* nobody noticed.
*/
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, globSync } from "node:fs";
const strings = new Set();
const plurals = new Set();
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const visit = (node) => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
const fn = node.expression.text;
const a0 = node.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) strings.add(a0.text);
if (fn === "plural" && node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1])) {
const other = node.arguments[1].properties.find((p) => ts.isPropertyAssignment(p) && p.name.getText(src) === "other");
const forms = {};
for (const p of node.arguments[1].properties) {
if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.initializer)) forms[p.name.getText(src)] = p.initializer.text;
}
if (other) plurals.add(JSON.stringify(forms));
}
}
ts.forEachChild(node, visit);
};
visit(src);
}
const out = { strings: [...strings].sort(), plurals: [...plurals].map((p) => JSON.parse(p)) };
if (process.argv.includes("--json")) console.log(JSON.stringify(out, null, 2));
else {
console.log(`${out.strings.length} strings, ${out.plurals.length} plural sets`);
const short = out.strings.filter((s) => s.length <= 30).length;
console.log(` ${short} short (<=30 chars), ${out.strings.length - short} longer`);
}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/*
* Write a Brotli and a gzip copy beside every compressible file in a web build.
*
* The server used to gzip the bundle again on every request that asked for it,
* at a level chosen for speed. These are made once, at the level chosen for
* size, and `server/src/static.ts` hands one out when the browser accepts it.
* Brotli at 11 is about 15% smaller than gzip for this bundle, and too slow to
* do per request, which is why it was never offered.
*
* node scripts/precompress.mjs web/dist
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, extname } from "node:path";
import { brotliCompressSync, constants, gzipSync } from "node:zlib";
const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".html", ".svg", ".json", ".webmanifest", ".txt", ".wasm"]);
// Below this, the encoding costs more than it saves.
const MIN_BYTES = 1024;
function* files(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) yield* files(p);
else yield p;
}
}
const root = process.argv[2];
if (!root) {
console.error("usage: precompress.mjs <dir>");
process.exit(2);
}
let count = 0;
let before = 0;
let after = 0;
for (const p of files(root)) {
if (!COMPRESSIBLE.has(extname(p)) || statSync(p).size < MIN_BYTES) continue;
const data = readFileSync(p);
const br = brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: data.length } });
writeFileSync(`${p}.br`, br);
writeFileSync(`${p}.gz`, gzipSync(data, { level: 9 }));
count++;
before += data.length;
after += br.length;
}
console.log(`precompressed ${count} files: ${(before / 1024).toFixed(0)} KB -> ${(after / 1024).toFixed(0)} KB brotli`);
+75
View File
@@ -0,0 +1,75 @@
/**
* The light inbox shot, with no Emulation.setDeviceMetricsOverride at all --
* the window is simply launched at the size we want. The emulation layer is the
* prime suspect for the mixed-theme frames every other approach produced.
*/
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
const OUT = process.argv[2] ?? ".";
const PORT = 9334;
const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check",
"--window-size=1420,790", "--force-device-scale-factor=1",
"--user-data-dir=/tmp/ihasmail-light-profile", "about:blank",
], { stdio: "ignore" });
const json = async (p) => { for (let i = 0; i < 60; i++) { try { return await (await fetch(`http://127.0.0.1:${PORT}${p}`)).json(); } catch { await sleep(250); } } throw new Error("no chrome"); };
const version = await json("/json/version");
let id = 1; const pending = new Map();
const ws = new WebSocket(version.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && pending.has(x.id)) { const { resolve, reject } = pending.get(x.id); pending.delete(x.id); x.error ? reject(new Error(JSON.stringify(x.error))) : resolve(x.result); } };
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const i = id++; pending.set(i, { resolve, reject }); ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) })); });
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
const cmd = (m, p) => send(m, p, sessionId);
await cmd("Page.enable"); await cmd("Runtime.enable");
const evaluate = async (expression) => {
const r = await cmd("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "eval failed");
return r.result.value;
};
const waitFor = async (expr, what, ms = 20000) => {
const end = Date.now() + ms;
while (Date.now() < end) { if (await evaluate(`!!(${expr})`)) return; await sleep(200); }
throw new Error(`timed out waiting for ${what}`);
};
try {
await cmd("Page.navigate", { url: "http://localhost:5173/" });
await sleep(1500);
console.log("viewport:", await evaluate(`window.innerWidth + 'x' + window.innerHeight`));
await evaluate(`
window.__set = (el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
window.__btn = (t, r=document) => [...r.querySelectorAll('button')].find(b => b.textContent.trim() === t);
`);
await evaluate(`(() => {
const i = [...document.querySelectorAll('input')];
window.__set(i.find(x => x.type === 'text' || x.type === 'email'), '[email protected]');
window.__set(document.querySelector('input[type=password]'), 'demo');
window.__btn('Sign in').click();
})()`);
await waitFor("document.querySelectorAll('.msg-row').length > 2", "the message list");
await sleep(1500);
await evaluate(`(() => { const r = document.querySelectorAll('.msg-row'); if (r[1]) r[1].click(); })()`);
await sleep(1500);
// The app's own control, the way a user switches theme.
await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => /light mode/i.test(x.getAttribute('aria-label') || x.title || ''));
if (b) b.click(); else document.documentElement.dataset.theme = 'light';
})()`);
await sleep(2000);
const bg = await evaluate(`getComputedStyle(document.body).backgroundColor`);
const topbar = await evaluate(`getComputedStyle(document.querySelector('.topbar')).backgroundColor`);
console.log("body:", bg, "topbar:", topbar);
if (parseInt(bg.match(/\d+/)[0], 10) < 200) throw new Error("page is not rendering light");
const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 });
await writeFile(`${OUT}/inbox-light.jpg`, Buffer.from(data, "base64"));
console.log("wrote inbox-light.jpg");
} finally { ws.close(); chrome.kill(); }
+314
View File
@@ -0,0 +1,314 @@
/**
* Regenerates most of the README screenshots from the mock server.
*
* Drives headless Chrome over CDP, so the viewport is exactly the size the
* images already use rather than whatever a window happens to be.
*
* npm run dev:mock # in another terminal
* node scripts/screenshots.mjs screenshots
* node scripts/screenshots-light.mjs screenshots
*
* Restart the mock before a run. The filters shot creates rules, so a second
* run against the same mock shows them twice.
*
* The files shot was taken by hand until 2026-08-27, and had gone stale twice
* over by the time anyone noticed. Anything the docs show should be generated
* from the mock, or it describes whatever the app looked like on the day
* somebody had a screenshot tool open.
*
* Two shots are deliberately not taken here:
*
* - **mobile**, because at the tail of this sequence the app would not render
* the message list at 500px within the wait. A short run of its own is
* reliable, and it is a screenshot, not a mystery worth solving.
*
* - **inbox-light**, because of setDeviceMetricsOverride. Swapping the theme
* under the emulation layer captures a *mixed* frame: the panes that
* re-rendered come out light while the rest of the chrome stays dark, with
* the DOM and computed styles insisting the whole page is light. The app is
* not at fault -- update() calls applyTheme() synchronously and the CSS does
* flip --bg to #f6f8fa. The compositor simply does not repaint everything a
* CSS-variable change touches while metrics are overridden. Launching Chrome
* at --window-size and never calling setDeviceMetricsOverride renders it
* correctly, which is what scripts/screenshots-light.mjs does.
*
* assertTheme() stays either way: without it this script wrote a dark
* screenshot under a light caption and reported success, and that is how the
* README came to show the same theme twice for months.
*/
import { spawn } from "node:child_process";
import { writeFile, mkdir } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
const OUT = process.argv[2];
if (!OUT) { console.error("usage: node shots.mjs <out-dir>"); process.exit(2); }
await mkdir(OUT, { recursive: true });
const PORT = 9333;
const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
`--user-data-dir=/tmp/ihasmail-shots-profile`, "about:blank",
], { stdio: "ignore" });
const json = async (path) => {
for (let i = 0; i < 60; i++) {
try { return await (await fetch(`http://127.0.0.1:${PORT}${path}`)).json(); }
catch { await sleep(250); }
}
throw new Error("Chrome did not come up");
};
const version = await json("/json/version");
let nextId = 1;
const pending = new Map();
const ws = new WebSocket(version.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
}
};
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
});
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
const cmd = (m, p) => send(m, p, sessionId);
await cmd("Page.enable");
await cmd("Runtime.enable");
let current = { width: 1420, height: 703, mobile: false };
const metrics = (width, height, mobile = false) => {
current = { width, height, mobile };
return cmd("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile });
};
/**
* Forces the whole page to repaint.
*
* Headless only repaints the layers that changed, and a theme swap changes CSS
* variables rather than any single element — so the capture came back with the
* message pane in the new theme and the rest of the app in the old one. Nudging
* the viewport by a pixel and back invalidates everything.
*/
const repaint = async () => {
// Detaching and reattaching the body invalidates every layer; nudging the
// viewport did not, and the capture kept coming back with mixed themes.
await evaluate(`(() => { const b = document.body; b.style.display = 'none'; void b.offsetHeight; b.style.display = ''; })()`);
await sleep(500);
};
const go = async (url) => { await cmd("Page.navigate", { url }); await sleep(1200); };
const evaluate = async (expression) => {
const r = await cmd("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "eval failed");
return r.result.value;
};
/** Polls a predicate inside the page until it is true, or gives up loudly. */
const waitFor = async (jsExpr, what, ms = 15000) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (await evaluate(`!!(${jsExpr})`)) return;
await sleep(200);
}
throw new Error(`timed out waiting for ${what}`);
};
/**
* Pins the theme, because setting it once is not enough.
*
* The app re-runs applyTheme() from its own setting whenever the settings store
* stirs, and that overwrote a plain attribute set during the settle before the
* capture — twice, silently, producing a "light" screenshot of the dark theme.
* A MutationObserver puts it back faster than anything can take it away.
*
* The check is the rendered background color: the attribute is what lied.
*/
const themeTest = (want) => want === "light"
? "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) > 200"
: "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) < 60";
const setTheme = async (want) => {
await evaluate(`(() => {
const html = document.documentElement;
const want = ${JSON.stringify(want)};
if (window.__themePin) window.__themePin.disconnect();
window.__themePin = new MutationObserver(() => { if (html.dataset.theme !== want) html.dataset.theme = want; });
window.__themePin.observe(html, { attributes: true, attributeFilter: ['data-theme'] });
html.dataset.theme = want;
})()`);
await waitFor(themeTest(want), `the ${want} theme to actually render`);
await repaint();
};
/** Refuses to write the file unless the page still looks the way it should. */
const assertTheme = async (want) => {
if (!(await evaluate(themeTest(want)))) throw new Error(`page is not rendering the ${want} theme at capture time`);
};
const shot = async (name) => {
const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 });
await writeFile(`${OUT}/${name}`, Buffer.from(data, "base64"));
console.log(" wrote", name);
};
// Helpers injected into the page: React-controlled inputs need the native setter.
const HELPERS = `
window.__set = (el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
window.__btn = (txt, root=document) => [...root.querySelectorAll('button')].find(b => b.textContent.trim() === txt);
window.__click = (sel) => { const el = document.querySelector(sel); if (el) el.click(); return !!el; };
window.__sel = (el, v) => { el.value = v; el.dispatchEvent(new Event('change', { bubbles: true })); };
`;
try {
console.log("chrome:", version.Browser);
// --- login (taller, as the existing shot is) ---
await metrics(1420, 759);
await go("http://localhost:5173/");
await evaluate(HELPERS);
await sleep(600);
await shot("login.jpg");
// --- sign in (a fresh profile prefills nothing, so both fields) ---
await evaluate(`(() => {
const inputs = [...document.querySelectorAll('input')];
const user = inputs.find(i => i.type === 'text' || i.type === 'email');
const pw = document.querySelector('input[type=password]');
window.__set(user, '[email protected]');
window.__set(pw, 'demo');
window.__btn('Sign in').click();
})()`);
await waitFor("document.querySelector('.msg-row') || document.querySelector('.nav-item')", "the app after sign-in");
await sleep(1500);
// --- inbox, dark, with a conversation open ---
await metrics(1420, 703);
await go("http://localhost:5173/mail");
await evaluate(HELPERS);
await waitFor("document.querySelectorAll('.msg-row').length > 2", "the message list");
await evaluate(`(() => { const r = document.querySelectorAll('.msg-row'); if (r[1]) r[1].click(); })()`);
await sleep(1800);
await shot("inbox-dark.jpg");
// --- the reply composer, still on the dark theme ---
await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => /^reply$/i.test(x.getAttribute('aria-label')||'') || /^reply$/i.test(x.textContent.trim()));
if (b) b.click();
})()`);
await sleep(1800);
await shot("compose.jpg");
// The recipient picker, taken here because the composer is already open. The
// site claims you can pick recipients by reading the address books rather
// than remembering a name, and this is that claim photographed. Doing it from
// a later step meant navigating back to the mail list, which turned out not
// to be reliable once the run had been through Files.
await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => x.getAttribute('aria-label') === 'Choose from address books');
if (b) b.click();
})()`);
await waitFor("/Choose recipients/.test(document.body.innerText)", "the recipient picker");
await evaluate(`(() => {
// Two ticked, so the shot shows a selection rather than an empty list.
for (const b of [...document.querySelectorAll('.menu-item input[type=checkbox]')].slice(0, 2)) b.click();
})()`);
await sleep(1500);
await shot("recipients.jpg");
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Cancel'); if (c) c.click(); })()`);
await sleep(600);
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
await sleep(800);
// (inbox-light is captured by scripts/screenshots-light.mjs -- see the header)
// --- calendar ---
await go("http://localhost:5173/calendar");
await waitFor("document.querySelector('.cal-grid, .calendar, [class*=cal]')", "the calendar");
await evaluate(HELPERS);
// The README caption promises the month view.
await evaluate(`(() => { const b = window.__btn('Month'); if (b) b.click(); })()`);
await sleep(1800);
await shot("calendar.jpg");
// --- contacts ---
await go("http://localhost:5173/contacts");
await waitFor("document.querySelector('[class*=contact]')", "the contact list");
// Open someone, so the detail pane is not an empty "Select a contact".
await evaluate(`(() => {
const hit = [...document.querySelectorAll('div, li, button, a')]
.filter(e => (e.textContent || '').trim().startsWith('Ada Lovelace'))
.sort((a, b) => a.textContent.length - b.textContent.length)[0];
if (hit) (hit.closest('li, button, a, [class*=row], [class*=item]') || hit).click();
})()`);
await waitFor("!/Select a contact/.test(document.body.innerText)", "the contact detail pane", 8000);
await sleep(1800);
await shot("contacts.jpg");
// --- files ---
// Was the one shot taken by hand, which is why it outlived two rewrites of
// the view it was meant to show. The tree makes it worth automating: opening
// a folder is now the difference between a screenshot of a file manager and a
// screenshot of a list.
await go("http://localhost:5173/files");
await waitFor("document.querySelector('.files-table, .files-layout')", "the files view");
await evaluate(`(() => {
// Expand the tree and open a folder, so the shot shows the pane doing its job.
const twisty = document.querySelector('.sidebar .nav-twisty');
if (twisty) twisty.click();
const folder = [...document.querySelectorAll('.sidebar .nav-item')].find(e => /Documents/.test(e.textContent || ""));
if (folder) folder.click();
})()`);
await sleep(1800);
await shot("files.jpg");
// --- filters, with rules that actually say something ---
await go("http://localhost:5173/settings/filters");
await evaluate(HELPERS);
await waitFor("[...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'New rule')", "the filters editor");
await evaluate(`(async () => {
const wait = (ms=350) => new Promise(r => setTimeout(r, ms));
const rules = [
{ name: 'Newsletters', field: 'list-id', op: 'exists', value: '', folder: 'Newsletters' },
{ name: 'From the boss', field: 'from', op: 'contains', value: '[email protected]', folder: 'Work' },
{ name: 'Receipts', field: 'subject', op: 'contains', value: 'invoice', folder: 'Archive' },
{ name: 'Build failures',field: 'subject', op: 'matches', value: '*FAILED*', folder: 'Work' },
];
for (const r of rules) {
window.__btn('New rule').click(); await wait();
const d = document.querySelector('.dialog');
window.__set(d.querySelector('input.input'), r.name); await wait(120);
const row = d.querySelector('.rule-row');
const sels = row.querySelectorAll('select');
window.__sel(sels[0], r.field); await wait(120);
const sels2 = d.querySelector('.rule-row').querySelectorAll('select');
if (sels2[1]) { window.__sel(sels2[1], r.op); await wait(120); }
const val = [...d.querySelector('.rule-row').querySelectorAll('input.input')].pop();
if (val && r.value) { window.__set(val, r.value); await wait(120); }
const arow = d.querySelector('.rule-row.actions');
const asels = arow.querySelectorAll('select');
if (asels[1]) { window.__sel(asels[1], r.folder); await wait(120); }
window.__btn('Done', d).click(); await wait();
}
const save = window.__btn('Save filters'); if (save && !save.disabled) save.click();
await wait(1500);
// Clear the "Filters saved" toast so it does not sit over a rule.
document.querySelectorAll('.toast, [class*=toast]').forEach(t => t.remove());
})()`);
await sleep(1200);
await shot("filters.jpg");
// (mobile is captured separately by shots-mobile.mjs)
console.log("done");
} finally {
ws.close();
chrome.kill();
}
+5
View File
@@ -0,0 +1,5 @@
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
export const UNVERSIONED: string;
export function formatVersion(commit: { date: string; subject?: string; sha: string }): string;
export function versionFromGit(): string | null;
export function resolveVersion(): string;
+108
View File
@@ -0,0 +1,108 @@
/**
* Work out this build's version: `2026.8.30+pr129`.
*
* 2026.8.30 the date of the commit this was built from
* +pr129 the pull request it arrived through
*
* The date leads because ihasmail's version used to be `2.16.<pr>`, where `16`
* was the Stalwart generation it targeted -- and Stalwart 1.0 will leave that
* with nowhere to go. `2.1` would sort *below* the `2.16` already deployed, so
* every image and About screen would read as a downgrade. Tying our
* numbering to somebody else's was the mistake; which Stalwart a build needs is
* said properly in the README badge and KNOWN-ISSUES, where it can be precise
* ("0.16 or newer; tested against 0.16.20") rather than one digit.
*
* The pull request moved into build metadata, after the `+`, because it is
* provenance rather than a position in a sequence: at a hundred merges a week
* it climbs without bound and says nothing about how new a build is. SemVer
* ignores everything after the `+` when comparing versions, which is the right
* reading -- two builds from the same day differ in where they came from, not
* in rank. Nothing here relies on that comparison anyway: images are pruned
* oldest-first by creation time and rollbacks name a git ref.
*
* A commit that did not arrive through a pull request carries its short SHA
* instead -- `2026.8.30+g1fa6578` -- which is honest about being some commit on
* that day rather than claiming a pull request it was only built after.
*
* The date is the commit's own, not today's, so rebuilding an old commit gives
* the same answer it gave the first time. It comes from the commit object,
* timezone included, so two machines agree.
*
* Nothing writes a version back into the tree: a committed one would always be
* describing a merge that had not happened yet, and every branch would collide
* on the same line. `package.json` no longer carries it either -- npm wants the
* field, so it stays at `0.0.0`, which is what an unversioned build reports and
* is meant to look wrong.
*
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
* whoever builds is responsible for computing it -- see ihasmail-deploy.sh.
*/
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
/** What a build with nothing to go on reports, and it should look wrong. */
export const UNVERSIONED = "0.0.0";
function git(...args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
}
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
/**
* The version for a commit, from the three things about it that decide one.
* Pure, so the rules can be exercised without a repository staged to produce
* them: `{ date: "2026-08-30", subject: "Merge pull request #129 from ...",
* sha: "1fa6578" }` gives `2026.8.30+pr129`.
*
* Leading zeros are stripped because a version field may not carry them, so
* September is `9` rather than `09`.
*/
export function formatVersion({ date, subject = "", sha }) {
const [y, m, d] = date.split("-");
const calendar = `${Number(y)}.${Number(m)}.${Number(d)}`;
const pr = PR_SUBJECT.exec(subject)?.[1];
return pr ? `${calendar}+pr${pr}` : `${calendar}+g${sha}`;
}
/**
* The version for the commit checked out here, or null when there is no git to
* ask -- an unpacked tarball, or the Docker build context.
*/
export function versionFromGit() {
let head;
let date;
try {
head = git("rev-parse", "--short", "HEAD");
// %cs is the committer date in the commit's own timezone, which is stored
// in the commit -- so this does not depend on the clock or zone of whoever
// is building.
date = git("show", "-s", "--format=%cs", "HEAD");
} catch {
return null;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
let subject = "";
try {
subject = git("show", "-s", "--format=%s", "HEAD");
} catch {
/* no subject to read; fall through to the SHA */
}
return formatVersion({ date, subject, sha: head });
}
/** Whatever the environment was told, else git, else an answer that looks wrong. */
export function resolveVersion() {
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
if (fromEnv) return fromEnv;
return versionFromGit() ?? UNVERSIONED;
}
// `node scripts/version.mjs` prints it, for shell scripts and CI.
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.stdout.write(resolveVersion() + "\n");
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@ihasmail/server",
"version": "2.16.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch --clear-screen=false src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
"mock": "tsx src/mock/index.ts",
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts",
"mock:no-keyword-sort": "MOCK_NO_KEYWORD_SORT=1 tsx src/mock/index.ts"
},
"dependencies": {
"@hono/node-server": "^2.1.1",
"hono": "^4.13.7"
},
"devDependencies": {
"@types/node": "^26.5.1",
"tsx": "^4.23.13",
"typescript": "^7.0.2"
}
}
+284
View File
@@ -0,0 +1,284 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
/**
* End-to-end self-service credential flows against the mock, which enforces
* the same rules a real 0.16 server does: the current password is checked,
* password policy is applied, and once 2FA is on every request wants a fresh
* TOTP code — except one authenticating with an app password.
*/
const PORT = 18797;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-account-flows";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
const app = createApp();
let cookie = "";
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
const res = await app.request(path, {
...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string>), ...(cookie ? { cookie } : {}) },
});
const setCookie = res.headers.get("set-cookie");
if (setCookie) cookie = setCookie.split(";")[0]!;
const text = await res.text();
return { status: res.status, body: text ? JSON.parse(text) : null };
}
const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) });
before(async () => {
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password" });
assert.equal(res.status, 200, "login should succeed against the mock");
});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
/**
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
* session-level capabilities. Looking for it at the top level alone reported
* every real 0.16 server as older than 0.16 — and now that the same check
* decides whether a sign-in is allowed at all, that mistake would lock
* everyone out rather than merely misroute credentials.
*/
test("the session is accepted on a server that advertises the registry per-account", async () => {
const res = await call("/api/auth/session");
assert.equal(res.status, 200);
assert.equal(res.body.ihasmail.server.edition, "oss");
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
});
test("the registry reports an account with nothing set up yet", async () => {
const res = await call("/api/account/security");
assert.equal(res.status, 200);
assert.equal(res.body.otpEnabled, false);
assert.deepEqual(res.body.appPasswords, []);
});
test("app passwords are created, listed once with their secret, and revoked", async () => {
const created = await post("/api/account/app-passwords", { description: "Thunderbird", current: "demo-password" });
assert.equal(created.status, 200);
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
assert.ok(created.body.id);
const list = await call("/api/account/security");
assert.equal(list.body.appPasswords.length, 1);
assert.equal(list.body.appPasswords[0].description, "Thunderbird");
assert.equal(list.body.appPasswords[0].secret, undefined, "the secret is never listed again");
const revoked = await post("/api/account/app-passwords/revoke", { id: created.body.id });
assert.equal(revoked.status, 200);
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
});
test("an app password needs the account password", async () => {
const missing = await post("/api/account/app-passwords", { description: "Stolen" });
assert.equal(missing.status, 400);
assert.equal(missing.body.error, "missing_fields");
const wrong = await post("/api/account/app-passwords", { description: "Stolen", current: "not-my-password" });
assert.equal(wrong.status, 403);
assert.equal(wrong.body.error, "invalid_credentials");
assert.deepEqual((await call("/api/account/security")).body.appPasswords, [], "nothing was created");
});
test("a checked session cannot mint one through the JMAP proxy instead", async () => {
// Signed in without "my own device", so the proxy reads every request.
const res = await call("/api/jmap", {
method: "POST",
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["x:AppPassword/set", { create: { n: { description: "Stolen" } } }, "0"]] }),
});
assert.equal(res.status, 403);
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
});
test("attachments are kept out of the disk cache of a device that is not the person's own", async () => {
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello" });
assert.equal(up.status, 200);
const { blobId } = (await up.json()) as { blobId: string };
const name = encodeURIComponent("Invoice_\u202Efdp.exe");
const res = await app.request(`/api/blob/a1/${blobId}/${name}?accept=text/plain`, { headers: { cookie } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("cache-control"), "no-store");
assert.equal(res.headers.get("content-disposition"), "attachment; filename*=UTF-8''Invoice_fdp.exe", "no direction override in the saved name");
await res.arrayBuffer();
});
test("a download passes a byte range through, for viewers that read in pieces", async () => {
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello world" });
const { blobId } = (await up.json()) as { blobId: string };
const url = `/api/blob/a1/${blobId}/greeting.txt?accept=text/plain`;
const part = await app.request(url, { headers: { cookie, range: "bytes=0-4" } });
assert.equal(part.status, 206);
assert.equal(part.headers.get("content-range"), "bytes 0-4/11");
assert.equal(part.headers.get("accept-ranges"), "bytes");
assert.equal(await part.text(), "hello");
const whole = await app.request(url, { headers: { cookie } });
assert.equal(whole.status, 200);
assert.equal(whole.headers.get("accept-ranges"), "bytes", "advertised even though Stalwart does not, so a PDF viewer asks");
assert.equal(await whole.text(), "hello world");
// Past the end, Stalwart sends the whole file rather than a 416.
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
assert.equal(beyond.status, 200);
assert.equal(await beyond.text(), "hello world");
// Anything that is not a plain byte range is not passed on.
const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } });
assert.equal(odd.status, 200);
await odd.arrayBuffer();
});
test("upstream caches let go of sessions that have aged out", async () => {
const { sweepUpstreamCaches, upstreamCacheSizes } = await import("./upstream.js");
// Signed in above, so this session has an entry.
assert.ok(upstreamCacheSizes().sessions >= 1);
sweepUpstreamCaches(Date.now() + 60 * 60_000);
assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 });
});
test("the mock refuses a contact photo given as a blob id, as Stalwart does", async () => {
const jmap = (methodCalls: unknown[]) => call("/api/jmap", { method: "POST", body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], methodCalls }) });
const card = (media: unknown) => ({ "@type": "Card", version: "1.0", kind: "individual", name: { full: "Probe" }, addressBookIds: { ab1: true }, media });
const res = await jmap([["ContactCard/set", { accountId: "a1", create: {
blob: card({ p: { "@type": "Media", kind: "photo", blobId: "b1", mediaType: "image/jpeg" } }),
inline: card({ p: { "@type": "Media", kind: "photo", uri: "data:image/jpeg;base64,AA", mediaType: "image/jpeg" } }),
} }, "s"]]);
assert.equal(res.status, 200);
const set = res.body.methodResponses[0][1];
assert.equal(set.notCreated.blob.description, "blobIds in media is not supported.");
assert.deepEqual(set.notCreated.blob.properties, ["media"]);
assert.ok(set.created.inline.id, "a data URI is accepted");
await jmap([["ContactCard/set", { accountId: "a1", destroy: [set.created.inline.id] }, "d"]]);
});
test("an app password needs a name", async () => {
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
assert.equal(res.status, 400);
assert.equal(res.body.error, "missing_fields");
});
test("the wrong current password is refused with the server's reason", async () => {
const res = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
assert.equal(res.status, 403);
assert.match(res.body.message, /Current secret is incorrect/);
});
test("the server's password policy is surfaced verbatim", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "short" });
assert.equal(res.status, 400);
assert.match(res.body.message, /at least 8 characters/);
});
test("a password unchanged from the old one is rejected before we ask upstream", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "demo-password" });
assert.equal(res.status, 400);
assert.equal(res.body.error, "unchanged");
});
test("changing the password keeps this session working", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" });
assert.equal(res.status, 200);
// The stored credential was re-sealed, so the next proxied call still passes
// upstream authentication with the new password.
assert.equal((await call("/api/auth/session")).status, 200);
assert.equal((await call("/api/account/security")).status, 200);
});
test("enabling 2FA rejects a code the new secret did not produce", async () => {
const begin = await post("/api/account/2fa/begin", {});
assert.equal(begin.status, 200);
assert.match(begin.body.url, /^otpauth:\/\/totp\//);
const res = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
assert.equal(res.status, 400);
assert.equal(res.body.code, undefined);
assert.match(res.body.message, /doesn't match/);
assert.equal((await call("/api/account/security")).body.otpEnabled, false, "nothing was stored");
});
test("enabling 2FA switches the session onto an app password so it survives", async () => {
const begin = await post("/api/account/2fa/begin", {});
const params = parseOtpauthUrl(begin.body.url);
assert.ok(params);
const res = await post("/api/account/2fa/enable", {
url: begin.body.url,
code: totpCode(params),
current: "a-brand-new-password",
});
assert.equal(res.status, 200);
assert.equal(res.body.sessionKept, true);
const state = await call("/api/account/security");
assert.equal(state.status, 200, "the session still authenticates upstream");
assert.equal(state.body.otpEnabled, true);
assert.equal(state.body.appPasswords.length, 1, "one app password was minted for this browser");
assert.match(state.body.appPasswords[0].description, /\(/, "it is named after the browser");
});
test("with 2FA on, a password change needs the current code too", async () => {
const withoutCode = await post("/api/account/password", { current: "a-brand-new-password", next: "yet-another-password" });
assert.equal(withoutCode.status, 403);
assert.match(withoutCode.body.message, /OTP code is required/);
});
test("2FA is switched off with the password and a current code", async () => {
const state = await call("/api/account/security");
assert.equal(state.body.otpEnabled, true);
// The enrollment secret is known only to the client, so disabling uses a code
// from the authenticator - here, the one the mock stored.
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
const params = parseOtpauthUrl(stored!);
assert.ok(params);
const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) });
assert.equal(res.status, 200);
assert.equal((await call("/api/account/security")).body.otpEnabled, false);
});
test("credential endpoints reject unauthenticated callers", async () => {
const saved = cookie;
cookie = "";
assert.equal((await call("/api/account/security")).status, 401);
assert.equal((await post("/api/account/password", { current: "a", next: "b" })).status, 401);
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
cookie = saved;
});
/**
* A sign-in carrying a two-factor code that the server rejects is almost never
* "wrong password". Stalwart accepts TOTP only through an OAuth flow and offers
* no password grant, so the concatenated form ihasmail sends cannot work — and
* saying "invalid credentials" sends the user to check a password that is fine.
*
* Reported as #75: 2FA sign-in failed with a bare 401 while an app password
* worked, which is Stalwart's documented route and gave no hint of itself.
*/
test("a rejected sign-in carrying a TOTP code explains itself", async () => {
const saved = cookie;
cookie = "";
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password", totp: "123456" });
cookie = saved;
assert.equal(res.status, 401);
assert.equal(res.body.error, "totp_unsupported", "not the generic invalid_credentials");
assert.match(res.body.message, /app password/i, "points at the route that does work");
assert.match(res.body.message, /probably fine/i, "does not blame the password");
});
test("a rejected sign-in without a code is still a plain credential failure", async () => {
// The explanation must not leak onto ordinary typos.
const saved = cookie;
cookie = "";
const res = await post("/api/auth/login", { username: "[email protected]", password: "wrong" });
cookie = saved;
assert.equal(res.status, 401);
assert.equal(res.body.error, "invalid_credentials");
});
+221
View File
@@ -0,0 +1,221 @@
import { config } from "./config.js";
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
/**
* Self-service credential management, over Stalwart's JMAP registry:
* `x:AccountPassword` (a singleton holding the password and the otpauth URL)
* and `x:AppPassword`.
*
* The registry crate arrived in 0.16, which is the oldest Stalwart ihasmail
* supports. Sign-in refuses anything older, so by the time any of this runs
* the registry is known to be there.
*/
const STALWART_CAP = "urn:stalwart:jmap";
const JMAP_CORE = "urn:ietf:params:jmap:core";
/** Stalwart's id for a singleton object; the number it encodes spells this. */
const SINGLETON = "singleton";
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
const MASKED = "[********]";
export interface AppPasswordRow {
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
}
export interface SecurityState {
otpEnabled: boolean;
appPasswords: AppPasswordRow[];
}
/** An error with a message meant for the person using the app. */
export class AccountError extends Error {
constructor(
message: string,
public readonly status = 400,
public readonly code = "account_error",
) {
super(message);
this.name = "AccountError";
}
}
interface Ctx {
authorization: string;
session: UpstreamSession;
username: string;
}
/* ------------------------------------------------------------------ */
/* Transport */
/* ------------------------------------------------------------------ */
function accountId(ctx: Ctx): string {
return (
ctx.session.primaryAccounts?.[STALWART_CAP] ??
ctx.session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
Object.keys(ctx.session.accounts ?? {})[0] ??
""
);
}
type Invocation = [string, Record<string, unknown>, string];
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
const res = await fetch(absoluteUpstream(ctx.session.apiUrl, ctx.session.baseUrl), {
method: "POST",
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502);
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
}
/**
* Pull the single result out of a /set, turning JMAP's several failure shapes
* into one error carrying whatever the server was willing to explain.
*/
function setResult(res: { methodResponses?: [string, unknown, string][] }, kind: "created" | "updated" | "destroyed"): Record<string, unknown> | null {
const [name, args] = res.methodResponses?.[0] ?? [];
if (!name) throw new AccountError("The mail server sent no response.", 502, "upstream");
if (name === "error") {
const err = args as { type?: string; description?: string };
if (err.type === "unknownMethod") {
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
}
throw new AccountError(err.description ?? `The mail server refused the request (${err.type ?? "error"}).`, 502, err.type ?? "upstream");
}
const body = args as Record<string, Record<string, unknown> | undefined>;
const notKind = kind === "created" ? "notCreated" : kind === "updated" ? "notUpdated" : "notDestroyed";
const failures = body[notKind];
const failure = failures && Object.values(failures)[0];
if (failure) {
const err = failure as { type?: string; description?: string; properties?: string[] };
throw new AccountError(describeSetError(err), err.type === "forbidden" ? 403 : 400, err.type ?? "invalid");
}
const ok = body[kind];
return ok ? ((Object.values(ok)[0] ?? {}) as Record<string, unknown>) : null;
}
function describeSetError(err: { type?: string; description?: string; properties?: string[] }): string {
if (err.description) return err.description;
if (err.type === "forbidden") return "The mail server refused the change.";
if (err.type === "overQuota") return "You have reached the number of app passwords this account allows.";
if (err.type === "invalidProperties") {
return err.properties?.length ? `The mail server rejected ${err.properties.join(", ")}.` : "The mail server rejected the value.";
}
return `The mail server refused the change (${err.type ?? "error"}).`;
}
/* ------------------------------------------------------------------ */
/* Operations */
/* ------------------------------------------------------------------ */
export async function getState(ctx: Ctx): Promise<SecurityState> {
const id = accountId(ctx);
const res = await jmap(ctx, [
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
["x:AppPassword/get", { accountId: id, ids: null }, "a"],
]);
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
const apps = listOf(res, "a");
return {
// The URL itself is masked; its presence is what tells us 2FA is on.
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
appPasswords: apps.map((a) => ({
id: String(a.id ?? ""),
description: String(a.description ?? "App password"),
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
})),
};
}
function listOf(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown>[] {
const call = res.methodResponses?.find((r) => r[2] === callId);
if (!call || call[0] === "error") return [];
const list = (call[1] as { list?: unknown }).list;
return Array.isArray(list) ? (list as Record<string, unknown>[]) : [];
}
function firstListItem(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown> | null {
return listOf(res, callId)[0] ?? null;
}
export async function changePassword(ctx: Ctx, opts: { current: string; next: string; otpCode?: string }): Promise<void> {
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
setResult(res, "updated");
}
export async function createAppPassword(ctx: Ctx, opts: { description: string }): Promise<{ id: string; secret: string }> {
const description = opts.description.trim() || "App password";
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
const created = setResult(res, "created");
const secret = created && typeof created.secret === "string" ? created.secret : "";
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
return { id: String(created?.id ?? description), secret };
}
export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
setResult(res, "destroyed");
}
/**
* Start enrollment: mint a secret and hand back the URL to show as a QR code.
* Nothing is stored until the user proves they can produce a code from it.
*/
export function beginOtpEnrollment(ctx: Ctx): { secret: string; url: string } {
const secret = generateSecret();
return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) };
}
/**
* Prove the user can produce a code from the secret they just scanned.
*
* Stalwart validates the credentials already on the account and never looks at
* the new secret, so without this an authenticator that was mistyped or out of
* step would lock the user out of their mailbox at the next sign-in.
*/
export function assertEnrollmentCode(url: string, code: string): void {
const params = parseOtpauthUrl(url);
if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url");
if (!verifyTotp(params, code)) {
throw new AccountError("That code doesn't match. Check your authenticator app and try the next code.", 400, "bad_code");
}
}
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
assertEnrollmentCode(opts.url, opts.code);
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
"s",
],
]);
setResult(res, "updated");
}
export async function disableOtp(ctx: Ctx, opts: { current: string; code: string }): Promise<void> {
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{
accountId: accountId(ctx),
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
},
"s",
],
]);
setResult(res, "updated");
}
export { MASKED };
+141
View File
@@ -0,0 +1,141 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./upstream.js";
/**
* The account locale used to be read only from `x:Account/get`, which needs
* the `sysAccountGet` permission — one the built-in `user` role is not given.
* Ordinary users therefore silently fell back to the browser locale. Stalwart
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
* so both are asked for and whichever answers wins. Both are 0.16 methods:
* this is a permissions fallback, not a version one.
*/
type Responses = [string, Record<string, unknown>, string][];
const settingsOk = (locale: string): Responses[number] => ["x:AccountSettings/get", { list: [{ id: "singleton", locale }] }, "s"];
const accountOk = (locale: string): Responses[number] => ["x:Account/get", { list: [{ id: "a1", locale }] }, "a"];
const failed = (id: string, type: string): Responses[number] => ["error", { type }, id];
test("prefers the locale a regular user is allowed to read", () => {
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
assert.equal(info.locale, "de-DE");
});
test("falls back to x:Account when the settings object is forbidden", () => {
const info = interpretAccountInfo([failed("s", "forbidden"), accountOk("sr_RS@latin")]);
assert.equal(info.locale, "sr-Latn-RS");
});
test("an account with no locale set yields none, rather than a guess", () => {
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
assert.equal(info.locale, null);
});
test("neither answering leaves the locale unknown", () => {
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null, permissions: [] });
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null, permissions: [] });
});
test("locales that carry no language are dropped, not passed through", () => {
assert.equal(interpretAccountInfo([settingsOk("C")]).locale, null);
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
});
test("a server without the registry is not asked for anything", async () => {
// Sign-in refuses these, so getAccountInfo should never reach the wire for
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
// fails the whole request rather than the one call.
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
assert.deepEqual(info, { locale: null, edition: null, permissions: [] });
});
test("no capabilities at all is treated the same way", async () => {
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
assert.equal(info.locale, null);
});
/**
* Where Stalwart actually advertises `urn:stalwart:jmap`.
*
* Not in the session-level `capabilities`: `Session::new` builds those from a
* fixed list that has never carried this capability, in any 0.16.x. It is
* handed out per-account instead, so it lands in `primaryAccounts` and in each
* account's `accountCapabilities`. Looking only at the session level called
* every real 0.16 server too old, which sent self-service credentials to a
* REST endpoint 0.16 had removed and made the About page report the wrong
* thing.
*
* This check now decides whether a sign-in is allowed at all, so getting it
* wrong would lock every user out of a perfectly good server.
*/
const STALWART = "urn:stalwart:jmap";
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
assert.equal(
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
true,
);
});
test("a 0.16 server is recognized from an account's capabilities", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:mail": {}, [STALWART]: {} } } },
primaryAccounts: {},
}),
true,
);
});
test("the session level still counts, for a server that ever advertises it there", () => {
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
});
test("a server that advertises it nowhere is one we do not support", () => {
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
assert.equal(hasStalwartRegistry(undefined), false);
});
test("a shared account carrying the capability is enough to recognize the server", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
accounts: { a1: { accountCapabilities: baseCaps }, a2: { accountCapabilities: { [STALWART]: {} } } },
primaryAccounts: {},
}),
true,
);
});
/**
* With a domain mapped to its own Stalwart (#238), everything asked about the
* account has to go to that server. The locale lookup resolved Stalwart's
* `apiUrl` against the default server instead, so a mapped account's locale
* was requested from a server that had never heard of it.
*/
test("account info is asked of the server that issued the session", async () => {
const seen: string[] = [];
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
seen.push(String(input instanceof Request ? input.url : input));
return new Response(JSON.stringify({ methodResponses: [], edition: "oss" }), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
try {
const session = {
capabilities: baseCaps,
accounts: { a1: { accountCapabilities: { [STALWART]: {} } } },
primaryAccounts: { [STALWART]: "a1" },
apiUrl: "https://mail.mapped.test/jmap/",
baseUrl: "https://mail.mapped.test",
};
await getAccountInfo("session-mapped-domain", "Basic x", session as never);
} finally {
globalThis.fetch = realFetch;
}
assert.ok(seen.length >= 2, "asks for both the locale and the edition");
for (const url of seen) assert.ok(url.startsWith("https://mail.mapped.test/"), `${url} went to the wrong server`);
});
+86
View File
@@ -0,0 +1,86 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { administrationAllowed, gateAdministration, grantsAdministration, mayNameRegistryMethod } from "./adminGate.js";
const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) });
/**
* With ADMINISTRATION=0 an administrator's browser must not be a way round the
* operator's decision. Hiding the menu would leave the proxy forwarding the
* very calls the menu made.
*/
test("mail, calendars and the rest pass untouched", () => {
const r = gateAdministration(req("Email/query", "Mailbox/get", "CalendarEvent/set", "FileNode/get", "Principal/getAvailability"));
assert.equal(r.ok, true);
});
test("the account's own registry objects can be read", () => {
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/get", "x:PublicKey/get", "x:MaskedEmail/query")).ok, true);
});
test("but not written: a credential minted here would outlive a borrowed session", () => {
for (const m of ["x:AppPassword/set", "x:AccountPassword/set", "x:MaskedEmail/set"]) {
assert.deepEqual(gateAdministration(req("x:AccountSettings/get", m)), { ok: false, method: m });
}
});
test("API keys are not the account's to reach from here at all", () => {
assert.deepEqual(gateAdministration(req("x:ApiKey/get")), { ok: false, method: "x:ApiKey/get" });
});
test("directory and server objects are refused, and named", () => {
for (const m of ["x:Account/get", "x:Domain/set", "x:Role/query", "x:Tenant/get", "x:SystemSettings/set", "x:DkimSignature/get"]) {
assert.deepEqual(gateAdministration(req("Email/get", m)), { ok: false, method: m });
}
});
test("a body that could name a registry method and cannot be read is refused rather than forwarded", () => {
assert.deepEqual(gateAdministration('{"methodCalls": [["x:Account/get"'), { ok: false, method: null });
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: "x:Account/get" })), { ok: false, method: null });
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]], note: "x:" })), { ok: false, method: null });
});
test("a body that cannot name a registry method is forwarded exactly as it came", () => {
// Most traffic from a session that may not administer: no parse, no rewrite.
const raw = '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Email/get",{"ids":["a"]},"c"]]}';
assert.equal(mayNameRegistryMethod(raw), false);
assert.deepEqual(gateAdministration(raw), { ok: true, body: raw });
});
test("a method name hidden behind a unicode escape is still found", () => {
// JSON.parse and the server both read \u0078 as "x"; a substring check alone would not.
const raw = '{"methodCalls":[["\\u0078:Account/get",{},"c"]]}';
assert.equal(mayNameRegistryMethod(raw), true);
assert.deepEqual(gateAdministration(raw), { ok: false, method: "x:Account/get" });
});
/**
* The operator's rule: administration only from a session signed in with
* "This is my own device" ticked, and never when the installation turned it off.
*/
test("administration needs both the installation and a device marked as the person's own", () => {
assert.equal(administrationAllowed(true, true), true);
assert.equal(administrationAllowed(true, false), false);
assert.equal(administrationAllowed(false, true), false);
});
test("an account counts as an administrator by the same test the menu makes", () => {
assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true);
assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true);
// The dashboard opens on less than a list: a count is only a query.
assert.equal(grantsAdministration(["sysAccountQuery"]), true);
assert.equal(grantsAdministration(["sysQueuedMessageQuery"]), true);
assert.equal(grantsAdministration(["sysMetricQuery", "sysMetricGet"]), true);
assert.equal(grantsAdministration(["sysMetricQuery"]), false);
assert.equal(grantsAdministration(["sysAccountGet", "sysDomainGet"]), false);
assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false);
});
test("what is forwarded is what was checked", () => {
// A duplicate key is read one way by JSON.parse; forwarding the parsed form
// means the server cannot read it the other way.
const raw = '{"methodCalls":[["x:Account/get",{},"a"]],"methodCalls":[["Email/get",{},"b"]]}';
const r = gateAdministration(raw);
assert.equal(r.ok, true);
if (r.ok) assert.equal(r.body, JSON.stringify({ methodCalls: [["Email/get", {}, "b"]] }));
});
+97
View File
@@ -0,0 +1,97 @@
/**
* What the JMAP proxy lets through for a session that may not administer:
* the operator turned it off (`ADMINISTRATION=0`), or the session was signed
* in without "This is my own device".
*
* Hiding the menu is not turning it off. `/api/jmap` forwards any method the
* browser sends, and Stalwart's registry answers whatever the credential's role
* allows -- so without this, an administrator could still manage accounts, or
* the whole server, from the browser console of an installation whose operator
* said no. With it off, the proxy refuses every `x:` method except the few that
* are about the signed-in account itself.
*
* An allowlist rather than a list of administrative objects, because the
* registry has dozens of them -- listeners, stores, tracers, system settings --
* and a new release adds more. An object not named here is refused, which errs
* toward the operator's decision.
*
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
* touched: they act on what the account can already reach.
*/
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "PublicKey", "MaskedEmail"]);
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
/**
* Whether a session may administer at all: the installation allows it, and
* the person signing in said the device is their own.
*
* The second half is the operator's rule, not Stalwart's. A borrowed laptop or
* a library machine is exactly where a session should not be able to reset a
* password or remove a domain, and "This is my own device" is the one thing
* the sign-in form already asks that says where it is being used. An untrusted
* session is also signed out when idle and wipes its local data, so nothing
* about it suits an administrator's work.
*/
export function administrationAllowed(enabled: boolean, remember: boolean): boolean {
return enabled && remember;
}
/**
* Whether an account's permissions would put Administration in its menu --
* the same test the client makes, so the server can say why it is missing
* without handing over the permissions themselves.
*
* The client's test is whether any section opens, and the dashboard opens on
* less than a list does: a count needs only the query, the metric history its
* query and get. The account and domain lists need more than their counts, so
* they add nothing here.
*/
export function grantsAdministration(permissions: readonly string[]): boolean {
const has = new Set(permissions);
return has.has("sysAccountQuery") || has.has("sysDomainQuery") || has.has("sysQueuedMessageQuery") || (has.has("sysMetricQuery") && has.has("sysMetricGet"));
}
/**
* Whether a body could hold a registry method name at all, so the common case
* -- mail, calendars, contacts from a session that may not administer -- skips
* the parse. A method name is a JSON string starting `x:`, which appears in the
* text as `"x:` unless written with a `\u` escape; a body with neither cannot
* contain one, and is forwarded exactly as it came.
*/
export function mayNameRegistryMethod(raw: string): boolean {
return raw.includes('"x:') || raw.includes("\\u");
}
/**
* Check a JMAP request body. On success, hands back the body to forward --
* serialized from what was inspected, so the server can never be sent
* something different from what was checked (a duplicate key, say, read one
* way here and another way there).
*/
export function gateAdministration(raw: string): GateResult {
if (!mayNameRegistryMethod(raw)) return { ok: true, body: raw };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, method: null };
}
const calls = (parsed as { methodCalls?: unknown } | null)?.methodCalls;
if (!Array.isArray(calls)) return { ok: false, method: null };
for (const call of calls) {
const name = Array.isArray(call) ? call[0] : undefined;
if (typeof name !== "string") return { ok: false, method: null };
if (!name.startsWith("x:")) continue;
const [object = "", op = ""] = name.slice(2).split("/");
/*
* Read, never write. The browser sends none of these itself -- password,
* app-password and 2FA changes go through /api/account, which checks the
* account password first -- so a write here could only come from
* somebody working the console of a session on a borrowed machine, and
* `x:AppPassword/set` would hand them a credential that outlives it.
*/
if (!SELF_SERVICE.has(object) || op === "set") return { ok: false, method: name };
}
return { ok: true, body: JSON.stringify(parsed) };
}
+75
View File
@@ -0,0 +1,75 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const dir = mkdtempSync(join(tmpdir(), "ihasmail-servers-"));
const file = join(dir, "servers.json");
writeFileSync(
file,
JSON.stringify({
_comment: ["A note, as the example file has."],
"plain.test": "https://mail.plain.test/",
"Linked.Test.": { url: "https://mail.linked.test", adminUrl: "https://admin.linked.test/" },
}),
);
process.env.STALWART_URL = "https://default.example";
process.env.STALWART_ADMIN_URL = "https://admin.default.example/";
process.env.STALWART_SERVERS_FILE = file;
const { adminPrefixFrom, adminUrlFor, advertisedOrigin, upstreamFor } = await import("./upstream.js");
const { config, parseStalwartServers } = await import("./config.js");
/**
* Where the dashboard's "Open Stalwart admin" points. STALWART_URL is how this
* server reaches Stalwart; STALWART_ADMIN_URL is where a browser opens its
* administration, and follows the same domain routing.
*/
test("a servers file entry may name its administration as well as its server, and a note is not a domain", () => {
assert.deepEqual(config.stalwartServers, { "plain.test": "https://mail.plain.test", "linked.test": "https://mail.linked.test" });
assert.deepEqual(config.stalwartAdminUrls, { "linked.test": "https://admin.linked.test" });
assert.equal(upstreamFor("[email protected]"), "https://mail.linked.test");
});
test("an unmapped domain and a bare username open the default administration", () => {
assert.equal(adminUrlFor("[email protected]"), "https://admin.default.example");
assert.equal(adminUrlFor("demo"), "https://admin.default.example");
});
test("a routed domain opens its own server's administration, and never the default's", () => {
assert.equal(adminUrlFor("[email protected]"), "https://admin.linked.test");
// Routed away, with no adminUrl of its own and nothing found: no link rather than the wrong server.
assert.equal(adminUrlFor("[email protected]"), null);
});
test("what the operator configured wins over what was found, and what was found fills the gap", () => {
assert.equal(adminUrlFor("[email protected]", "https://found.example/admin/"), "https://admin.default.example");
assert.equal(adminUrlFor("[email protected]", "https://found.example/admin/"), "https://admin.linked.test");
// The routed domain without an adminUrl takes what its own server said.
assert.equal(adminUrlFor("[email protected]", "https://mail.plain.test/admin/"), "https://mail.plain.test/admin/");
});
/** Finding the administration on the server itself, as production's answered on 2026-09-15. */
test("the web interface's prefix is read from the applications Stalwart has installed", () => {
const got = (list: unknown[]) => adminPrefixFrom([["x:Application/query", { ids: ["a"] }, "q"], ["x:Application/get", { list }, "g"]]);
assert.equal(got([{ enabled: true, description: "Stalwart Web Interface", urlPrefix: { "/admin": true, "/account": true } }]), "/admin");
assert.equal(got([{ enabled: false, urlPrefix: { "/admin": true } }]), null);
assert.equal(got([{ enabled: true, urlPrefix: { "/console": true } }]), null);
assert.equal(got([]), null);
// May not read applications: not an answer, so Stalwart's own default.
assert.equal(adminPrefixFrom([["error", { type: "forbidden" }, "q"], ["error", { type: "forbidden" }, "g"]]), "/admin");
});
test("the origin is the one Stalwart advertises, even when it is reached on a private address", () => {
assert.equal(advertisedOrigin({ apiUrl: "https://mail.example.com/jmap/", baseUrl: "http://127.0.0.1:8080" }), "https://mail.example.com");
assert.equal(advertisedOrigin({ apiUrl: "/jmap/", baseUrl: "https://mail.example.com" }), "https://mail.example.com");
});
test("the shipped example loads through the parser that reads it", () => {
const example = new URL("../../stalwart-servers.example.json", import.meta.url);
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
assert.ok(Object.keys(parsed.urls).length > 0);
assert.ok(!("_comment" in parsed.urls));
assert.equal(Object.keys(parsed.adminUrls).length, 1);
});
+146
View File
@@ -0,0 +1,146 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
test("CSRF guard rejects API POSTs without the custom header", async () => {
const app = createApp();
const res = await app.request("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
assert.equal(res.status, 403);
});
test("unauthenticated JMAP calls are rejected", async () => {
const app = createApp();
const res = await app.request("/api/jmap", { method: "POST", headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" });
assert.equal(res.status, 401);
});
test("cross-site fetches are rejected", async () => {
const app = createApp();
const res = await app.request("/api/health", { headers: { "sec-fetch-site": "cross-site" } });
assert.equal(res.status, 403);
});
test("health and security headers", async () => {
const app = createApp();
const res = await app.request("/api/health");
assert.equal(res.status, 200);
assert.equal(res.headers.get("x-content-type-options"), "nosniff");
assert.equal(res.headers.get("x-frame-options"), "DENY");
});
test("image proxy refuses private targets", async () => {
const app = createApp();
// no session -> 401 first; so exercise the handler directly via a logged-in-less path is not possible; check the URL validation ordering instead
const res = await app.request("/api/image?url=http://127.0.0.1/x");
assert.equal(res.status, 401);
});
test("a compressed upstream blob is not forwarded with the compressed length", async () => {
const { forwardedContentLength } = await import("./app.js");
// gzip: the body we forward has already been decompressed, so the length on
// the wire describes different bytes and must not be copied (issue #76).
const gz = new Headers({ "content-encoding": "gzip", "content-length": "384" });
assert.equal(forwardedContentLength(gz), null);
// identity, spelled out or absent: the length describes the body we send.
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "identity", "content-length": "1157" })), "1157");
assert.equal(forwardedContentLength(new Headers({ "content-length": "1157" })), "1157");
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "BR", "content-length": "384" })), null);
// Nothing to forward is not an error.
assert.equal(forwardedContentLength(new Headers()), null);
});
test("a Sieve script larger than a compressing hop's threshold survives the proxy", async () => {
const http = await import("node:http");
const zlib = await import("node:zlib");
const { forwardedContentLength } = await import("./app.js");
const script =
"# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments\nrequire [\"fileinto\"];\n\n" +
["a", "b", "c"]
.map(
(k) =>
`# rule:{"id":"r${k}","name":"From ${k}@example.com","enabled":true,"join":"allof","tests":[{"type":"header","header":"from","op":"contains","value":"${k}@example.com"}],"actions":[{"type":"fileinto","mailbox":"INBOX/${k}"}]}\n` +
`if header :contains "from" "${k}@example.com"\n{\n fileinto "INBOX/${k}";\n}\n\n`,
)
.join("");
const gz = zlib.gzipSync(Buffer.from(script));
assert.ok(gz.length < Buffer.byteLength(script), "the script has to compress for this test to mean anything");
// A hop that compresses regardless of what we asked for.
const origin = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/sieve", "content-encoding": "gzip", "content-length": String(gz.length) });
res.end(gz);
});
await new Promise<void>((r) => origin.listen(0, () => r()));
const port = (origin.address() as { port: number }).port;
try {
const up = await fetch(`http://127.0.0.1:${port}/`);
// What the blob route forwards.
const headers = new Headers({ "content-type": "application/sieve; charset=utf-8" });
const cl = forwardedContentLength(up.headers);
if (cl) headers.set("Content-Length", cl);
const out = new Response(await up.arrayBuffer(), { status: 200, headers });
assert.equal(out.headers.get("content-length"), null);
assert.equal(await out.text(), script);
} finally {
origin.close();
}
});
test("only a PDF blob may be framed, and only by us", async () => {
/*
* The PDF preview is an iframe, and the blanket X-Frame-Options: DENY on
* every response blocked it -- the dialog showed Chrome's "refused to
* connect" where the file should have been. The middleware now leaves a
* header a route has already set, so this pins both halves: the exception
* exists, and it did not become the rule.
*/
const app = createApp();
const health = await app.request("/api/health");
assert.equal(health.headers.get("x-frame-options"), "DENY");
const { securityHeadersFor } = await import("./app.js");
assert.equal(securityHeadersFor("application/pdf", true), "SAMEORIGIN");
assert.equal(securityHeadersFor("application/pdf", false), "DENY");
assert.equal(securityHeadersFor("image/png", true), "DENY");
assert.equal(securityHeadersFor("text/html", true), "DENY");
});
/*
* #239: retrying through an outage must not lock somebody out of the recovery.
*
* STALWART_URL at the top of this file is 127.0.0.1:1 — nothing listens there,
* so every sign-in here is the outage case. Before the fix, the eleventh of
* these came back 429 and stayed 429 for fifteen minutes, outliving whatever
* had actually been wrong.
*/
test("an unreachable upstream does not spend login attempts", async () => {
const app = createApp();
const login = () =>
app.request("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
});
// Comfortably past LOGIN_RATE_LIMIT, which defaults to 10.
for (let i = 0; i < 25; i++) {
const res = await login();
assert.notEqual(res.status, 429, `attempt ${i + 1} was rate limited`);
assert.ok(res.status === 502 || res.status === 504, `attempt ${i + 1} said ${res.status}`);
}
});
test("an unreachable upstream says it is not the password", async () => {
const app = createApp();
const res = await app.request("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
});
const body = (await res.json()) as { error: string; message: string };
assert.notEqual(body.error, "invalid_credentials");
assert.match(body.message, /not a problem with your password/i);
});
+1241
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
/**
* `BASE_PATH` is read once, into `config`, so these mount the app by argument
* instead of re-importing the module with a different environment. The
* root-mounted half is the one that matters most: every instance in existence
* is at `/`, and this feature has to be invisible to them.
*/
test("at the root, the API is exactly where it was", async () => {
const app = createApp("");
const res = await app.request("/api/health");
assert.equal(res.status, 200);
});
test("under a prefix, the API moves with it", async () => {
const app = createApp("/mail");
const res = await app.request("/mail/api/health");
assert.equal(res.status, 200);
const body = (await res.json()) as { ok?: boolean };
assert.equal(body.ok, true);
});
test("under a prefix, the unprefixed API is gone", async () => {
// Not merely unrouted: a proxy that forwards without the prefix, against a
// server told to expect one, would otherwise appear to half-work -- the API
// answering while the app shell it belongs to 404s.
const app = createApp("/mail");
const res = await app.request("/api/health");
assert.equal(res.status, 404);
});
/*
* Whether a route reached the static handler, without depending on there being
* a web build in the tree. With one it serves the index; without one it says
* the build is missing. Either is proof the request got that far -- a routing
* mistake is the 404, and asserting on 200 or 503 would make these tests pass
* or fail on whether somebody had run `npm run build` first.
*/
const reachedTheApp = (status: number) => status === 200 || status === 503;
test("a deep SPA route under the prefix reaches the static handler", async () => {
const app = createApp("/mail");
const res = await app.request("/mail/calendar/week/2026-09-01");
assert.ok(reachedTheApp(res.status), `expected the app shell, got ${res.status}`);
});
test("a path that only shares the prefix's letters is not the app", async () => {
// `/mailbox` under a `/mail` mount belongs to whatever else the proxy
// serves on this host; answering it with our shell would shadow it.
const app = createApp("/mail");
assert.equal((await app.request("/mailbox")).status, 404);
assert.equal((await app.request("/")).status, 404);
});
test("the root mount still serves the SPA from the root", async () => {
const app = createApp("");
assert.ok(reachedTheApp((await app.request("/calendar/week/2026-09-01")).status));
assert.ok(reachedTheApp((await app.request("/")).status));
});
+94
View File
@@ -0,0 +1,94 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { inRange, isTrustedProxy, resolveClientIp } from "./clientip.js";
/**
* The rate limiter keys on whatever this returns, so anything a client can
* choose is a way to sidestep it. nginx's `$proxy_add_x_forwarded_for`
* *appends*, so a client sending `X-Forwarded-For: 1.2.3.4` reaches us as
* "1.2.3.4, <their real address>" — reading the leftmost entry hands them a
* key they can change per request.
*/
const cfg = { trustProxy: true, trustedProxies: [] as string[] };
const direct = { trustProxy: false, trustedProxies: [] as string[] };
test("CIDR matching covers both families and single addresses", () => {
assert.equal(inRange("10.1.2.3", "10.0.0.0/8"), true);
assert.equal(inRange("11.1.2.3", "10.0.0.0/8"), false);
assert.equal(inRange("172.16.5.4", "172.16.0.0/12"), true);
assert.equal(inRange("172.32.5.4", "172.16.0.0/12"), false);
assert.equal(inRange("127.0.0.1", "127.0.0.1"), true, "a bare address is a /32");
assert.equal(inRange("::1", "::1/128"), true);
assert.equal(inRange("fd00::5", "fc00::/7"), true);
assert.equal(inRange("2001:db8::1", "fc00::/7"), false);
assert.equal(inRange("10.1.2.3", "not-a-range"), false);
assert.equal(inRange("10.1.2.3", "::1/128"), false, "families do not cross");
});
test("loopback and private peers are trusted by default", () => {
for (const p of ["127.0.0.1", "::1", "10.0.0.5", "172.17.0.1", "192.168.1.9", "fd00::2"]) {
assert.equal(isTrustedProxy(p, cfg), true, p);
}
for (const p of ["8.8.8.8", "2001:db8::1"]) {
assert.equal(isTrustedProxy(p, cfg), false, p);
}
});
test("the real client is taken from the right, not the left", () => {
// What nginx produces when the client sent a forged header of their own.
const ip = resolveClientIp("172.17.0.1", { forwardedFor: "1.2.3.4, 203.0.113.9" }, cfg);
assert.equal(ip, "203.0.113.9", "the entry our own proxy observed");
});
test("a forged chain cannot move the rate-limit key", () => {
const forged = ["9.9.9.9", "8.8.8.8, 7.7.7.7", "203.0.113.1, 203.0.113.2, 203.0.113.3"];
const seen = forged.map((f) => resolveClientIp("127.0.0.1", { forwardedFor: `${f}, 198.51.100.7` }, cfg));
assert.deepEqual(seen, ["198.51.100.7", "198.51.100.7", "198.51.100.7"], "always the same real client");
});
test("hops we run ourselves are skipped over", () => {
// client → our edge proxy → our app proxy → us
const ip = resolveClientIp("127.0.0.1", { forwardedFor: "198.51.100.7, 10.0.0.2, 10.0.0.3" }, cfg);
assert.equal(ip, "198.51.100.7");
});
test("a peer we do not run is believed only about itself", () => {
const ip = resolveClientIp("8.8.8.8", { forwardedFor: "1.2.3.4" }, cfg);
assert.equal(ip, "8.8.8.8", "an untrusted peer cannot name its own client");
});
test("forwarding headers are ignored entirely when the proxy is not trusted", () => {
assert.equal(resolveClientIp("203.0.113.5", { forwardedFor: "1.2.3.4", realIp: "5.6.7.8" }, direct), "203.0.113.5");
});
test("X-Real-IP is a fallback, never an override", () => {
assert.equal(resolveClientIp("127.0.0.1", { realIp: "198.51.100.7" }, cfg), "198.51.100.7");
assert.equal(
resolveClientIp("127.0.0.1", { forwardedFor: "198.51.100.7", realIp: "1.2.3.4" }, cfg),
"198.51.100.7",
"the chain wins where there is one",
);
});
test("junk in the chain is discarded rather than used as a key", () => {
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "not-an-ip, 198.51.100.7" }, cfg), "198.51.100.7");
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "not-an-ip" }, cfg), "127.0.0.1", "falls back to the peer");
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "" }, cfg), "127.0.0.1");
});
test("bracketed and IPv4-mapped forms are normalized", () => {
assert.equal(resolveClientIp("::1", { forwardedFor: "[2001:db8::5]" }, cfg), "2001:db8::5");
assert.equal(resolveClientIp("::1", { forwardedFor: "::ffff:198.51.100.7" }, cfg), "198.51.100.7");
});
test("an explicit trusted list replaces the defaults", () => {
const only = { trustProxy: true, trustedProxies: ["203.0.113.0/24"] };
assert.equal(resolveClientIp("203.0.113.9", { forwardedFor: "198.51.100.7" }, only), "198.51.100.7");
// Loopback is no longer trusted once a list is given.
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "198.51.100.7" }, only), "127.0.0.1");
});
test("a chain of nothing but our own proxies still yields an address", () => {
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "10.0.0.2, 10.0.0.3" }, cfg), "10.0.0.2");
});
+117
View File
@@ -0,0 +1,117 @@
import { isIP } from "node:net";
/**
* Work out who is really talking to us, for rate limiting and session records.
*
* `X-Forwarded-For` is a list that each hop appends to, so the entry nearest
* the right is the one our own proxy observed and the entries to its left were
* supplied by whoever came before — including the client. nginx's
* `$proxy_add_x_forwarded_for` appends, so a client sending
* `X-Forwarded-For: 1.2.3.4` arrives as `1.2.3.4, <their real address>`:
* reading the leftmost entry hands an attacker a rate-limit key they can
* change at will. Read from the right instead, skipping hops we run ourselves,
* and only believe the header at all when the peer is a proxy we trust.
*/
/** Peers whose forwarding headers are believed when none are configured. */
const DEFAULT_TRUSTED = ["127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"];
export interface TrustConfig {
trustProxy: boolean;
/** CIDRs or bare addresses; empty means DEFAULT_TRUSTED. */
trustedProxies: string[];
}
function toBits(addr: string): { value: bigint; width: number } | null {
const v = isIP(addr);
if (v === 4) {
const parts = addr.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
return { value: parts.reduce((acc, n) => (acc << 8n) | BigInt(n), 0n), width: 32 };
}
if (v === 6) {
// Expand "::" and any embedded IPv4 tail into eight 16-bit groups.
let text = addr;
const tail = /:(\d+\.\d+\.\d+\.\d+)$/.exec(text);
if (tail) {
const b = tail[1]!.split(".").map(Number);
text = `${text.slice(0, tail.index)}:${((b[0]! << 8) | b[1]!).toString(16)}:${((b[2]! << 8) | b[3]!).toString(16)}`;
}
const [head, rest] = text.split("::");
const left = head ? head.split(":").filter(Boolean) : [];
const right = rest !== undefined ? (rest ? rest.split(":").filter(Boolean) : []) : null;
const groups = right === null ? left : [...left, ...Array<string>(8 - left.length - right.length).fill("0"), ...right];
if (groups.length !== 8) return null;
let value = 0n;
for (const g of groups) {
const n = parseInt(g, 16);
if (!Number.isInteger(n) || n < 0 || n > 0xffff) return null;
value = (value << 16n) | BigInt(n);
}
return { value, width: 128 };
}
return null;
}
/** Is `addr` inside `range`, which may be a CIDR or a single address? */
export function inRange(addr: string, range: string): boolean {
const [net, bitsText] = range.trim().split("/");
const a = toBits(addr);
const n = toBits(net ?? "");
if (!a || !n || a.width !== n.width) return false;
const bits = bitsText === undefined ? n.width : Number(bitsText);
if (!Number.isInteger(bits) || bits < 0 || bits > n.width) return false;
if (bits === 0) return true;
const shift = BigInt(n.width - bits);
return a.value >> shift === n.value >> shift;
}
export function isTrustedProxy(addr: string, cfg: TrustConfig): boolean {
const ranges = cfg.trustedProxies.length ? cfg.trustedProxies : DEFAULT_TRUSTED;
return ranges.some((r) => inRange(addr, r));
}
export interface ForwardHeaders {
forwardedFor?: string;
realIp?: string;
}
/**
* The client address to attribute a request to. `peer` is the socket address,
* which is the only part nobody downstream can forge.
*/
export function resolveClientIp(peer: string, headers: ForwardHeaders, cfg: TrustConfig): string {
if (!cfg.trustProxy || !peer || peer === "unknown") return peer || "unknown";
// A peer we do not run is not allowed to tell us who its client is.
if (!isTrustedProxy(peer, cfg)) return peer;
const chain = (headers.forwardedFor ?? "")
.split(",")
.map((s) => s.trim().replace(/^\[|\]$/g, "").replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/i, ""))
.filter((s) => isIP(s) !== 0);
// Rightmost first: the last hop we trust is ours, anything left of the first
// untrusted entry was written by someone we have no reason to believe.
for (let i = chain.length - 1; i >= 0; i--) {
if (!isTrustedProxy(chain[i]!, cfg)) return chain[i]!;
}
if (chain.length) return chain[0]!;
const real = headers.realIp?.trim();
return real && isIP(real) !== 0 ? real : peer;
}
/**
* The key a rate limit counts an address under.
*
* An IPv4 address is the key as it is. An IPv6 address is cut to its /64: that
* is the smallest block an ISP or a VPS hands out, so anyone who holds one
* address holds 2^64 of them, and a limit keyed on the full address is no
* limit. Everyone behind one /64 shares a budget, which is the same bargain an
* IPv4 NAT already makes.
*/
export function rateLimitKey(ip: string): string {
if (isIP(ip) !== 6) return ip;
const bits = toBits(ip);
if (!bits) return ip;
const prefix = bits.value >> 64n;
const groups = [48n, 32n, 16n, 0n].map((s) => ((prefix >> s) & 0xffffn).toString(16));
return `${groups.join(":")}::/64`;
}
+107
View File
@@ -0,0 +1,107 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/*
* A static root of our own, built before the app is imported.
*
* CI runs `npm test` before `npm run build`, so `web/dist` does not exist when
* these run: pointing at it would serve the "web build not found" fallback,
* which is short, plain text and rightly uncompressed. That failure looked
* exactly like compression being broken.
*/
const root = mkdtempSync(join(tmpdir(), "ihasmail-compress-"));
mkdirSync(join(root, "assets"));
const script = `/* ${"x".repeat(40_000)} */\n`;
writeFileSync(join(root, "assets", "app.js"), script);
writeFileSync(join(root, "index.html"), `<!doctype html><title>t</title>${"<p>hello</p>".repeat(400)}`);
process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
test("an asset is gzipped when the client asks for it", async () => {
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), "gzip");
assert.match(res.headers.get("vary") ?? "", /accept-encoding/i);
});
test("a client that does not ask for gzip does not get it", async () => {
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), null);
});
test("gzip actually makes the asset smaller", async () => {
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
const gz = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } })).arrayBuffer();
assert.ok(gz.byteLength < plain.byteLength / 2, `${gz.byteLength} should be well under ${plain.byteLength}`);
});
test("a gzipped response decodes to the bytes we would have sent plain", async () => {
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
const decoded = await new Response(res.body!.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
assert.deepEqual(Buffer.from(decoded), Buffer.from(plain));
});
test("the app shell is gzipped", async () => {
const res = await createApp().request("/", { headers: { "accept-encoding": "gzip" } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), "gzip");
});
test("proxy routes that forward upstream bytes are never compressed", async () => {
// Unauthenticated, so these stop at 401 -- enough to prove the middleware
// declines the path, which is what issue #76 was about.
const app = createApp();
for (const path of ["/api/blob/a/b/c.pdf", "/api/image?url=https://example.com/x.png", "/api/ics?url=https://example.com/x.ics"]) {
const res = await app.request(path, { headers: { "accept-encoding": "gzip" } });
assert.equal(res.headers.get("content-encoding"), null, `${path} must not be compressed`);
}
});
test("the push stream is never compressed", async () => {
const res = await createApp().request("/api/events", { headers: { "accept-encoding": "gzip" } });
assert.equal(res.headers.get("content-encoding"), null);
});
test("the liveness probe is not compressed, since gzip would make it bigger", async () => {
const res = await createApp().request("/api/health", { headers: { "accept-encoding": "gzip" } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), null);
});
test("advertised upstream URLs are pinned to the configured origin", async () => {
const { absoluteUpstream } = await import("./upstream.js");
const pinned = absoluteUpstream("https://mail.public.example/jmap/eventsource/?types=*", "http://stalwart:8080");
assert.equal(pinned, "http://stalwart:8080/jmap/eventsource/?types=*");
// A relative URL still resolves against the base, as before.
assert.equal(absoluteUpstream("/jmap/", "http://stalwart:8080/"), "http://stalwart:8080/jmap/");
});
test("the data path is rate limited per session, and login stays on its own budget", async () => {
// No session: every call is refused before the limiter, so it must never 429.
const app = createApp();
for (let i = 0; i < 5; i++) {
const res = await app.request("/api/jmap", { method: "POST",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" });
assert.equal(res.status, 401);
}
// The limiter itself: a fresh key gets its budget and nothing more.
const { RateLimiter } = await import("./ratelimit.js");
const l = new RateLimiter(3, 60_000);
assert.deepEqual([l.check("s1"), l.check("s1"), l.check("s1"), l.check("s1")], [true, true, true, false]);
assert.ok(l.retryAfterSeconds("s1") >= 1);
assert.equal(l.check("s2"), true, "another session is not affected");
});
test("a response to a client that offered no encoding is not touched by the compressor", async () => {
const res = await createApp().request("/assets/app.js"); // no Accept-Encoding at all
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), null);
assert.equal(res.headers.get("vary"), null, "no Vary: the middleware never ran");
});
+40
View File
@@ -0,0 +1,40 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { assertImmutable } from "./config.js";
function tempRoot(): string {
return mkdtempSync(join(tmpdir(), "ihasmail-immutable-"));
}
test("IMMUTABLE refuses a configured SESSION_FILE", () => {
const root = tempRoot();
try {
assert.throws(() => assertImmutable("/data/sessions.json", root), /SESSION_FILE is \/data\/sessions\.json/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("IMMUTABLE refuses a writable root, and leaves no probe behind", () => {
const root = tempRoot();
try {
assert.throws(() => assertImmutable("", root), /is writable/);
assert.equal(existsSync(join(root, ".immutable-probe")), false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("IMMUTABLE accepts a root it cannot write to", () => {
const root = tempRoot();
try {
chmodSync(root, 0o555);
assert.doesNotThrow(() => assertImmutable("", root));
} finally {
chmodSync(root, 0o755);
rmSync(root, { recursive: true, force: true });
}
});
+373
View File
@@ -0,0 +1,373 @@
import { resolveVersion } from "../../scripts/version.mjs";
import { normalizeBasePath } from "../../scripts/basePath.mjs";
import { randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
/** Minimal .env loader (no dependency): first match wins, never overrides real env. */
function loadDotEnv() {
const candidates = [resolve(process.cwd(), ".env"), fileURLToPath(new URL("../../.env", import.meta.url)), fileURLToPath(new URL("../.env", import.meta.url))];
for (const file of candidates) {
if (!existsSync(file)) continue;
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
if (!m || line.trim().startsWith("#")) continue;
let v = m[2]!;
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
if (process.env[m[1]!] === undefined) process.env[m[1]!] = v;
}
break;
}
}
loadDotEnv();
function env(name: string, fallback?: string): string {
const v = process.env[name];
if (v === undefined || v === "") {
if (fallback === undefined) throw new Error(`Missing required environment variable ${name}`);
return fallback;
}
return v;
}
function bool(name: string, fallback: boolean): boolean {
const v = process.env[name];
if (v === undefined || v === "") return fallback;
return ["1", "true", "yes", "on"].includes(v.toLowerCase());
}
function int(name: string, fallback: number): number {
const v = process.env[name];
if (v === undefined || v === "") return fallback;
const n = Number.parseInt(v, 10);
if (!Number.isFinite(n)) throw new Error(`Invalid integer for ${name}: ${v}`);
return n;
}
const isProd = process.env.NODE_ENV === "production";
let appSecret = process.env.APP_SECRET ?? "";
if (!appSecret || appSecret === "change-me") {
if (isProd) {
throw new Error("APP_SECRET must be set to a strong random value in production");
}
appSecret = randomBytes(32).toString("base64");
console.warn(
"[ihasmail] APP_SECRET not set - using an ephemeral secret (persisted sessions will not survive restarts)",
);
}
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
/**
* Declares that this instance is running as an immutable container: read-only
* root filesystem, nothing durable of its own, replaceable by its image.
*
* It is a claim the process checks rather than one it takes on trust, because
* the failure it guards against is silent. Left to itself the server survives
* a read-only filesystem perfectly well -- sessions are held in memory and the
* write is best-effort, so the only sign that `SESSION_FILE` is going nowhere
* is one warning at the first login, long after anyone was watching. The
* instance looks healthy right up until it is replaced and everyone is signed
* out. Setting IMMUTABLE turns both halves of that into a refusal to start.
*/
const immutable = bool("IMMUTABLE", false);
const sessionFile = process.env.SESSION_FILE ?? "";
/**
* Refuse to run when the promise IMMUTABLE makes is not one this instance can
* keep. Exported so it can be tested without a read-only filesystem to hand.
*/
export function assertImmutable(sessionFile: string, root: string): void {
// The image sets SESSION_FILE=/data/sessions.json, so this is a deliberate
// refusal rather than a formality: running immutably means clearing it. It
// is not quietly ignored, because a configured path that silently persists
// nothing is exactly the failure this flag exists to surface.
if (sessionFile) {
throw new Error(
`IMMUTABLE is set, but SESSION_FILE is ${sessionFile}. An immutable instance keeps no durable state of its own: ` +
"pass SESSION_FILE= (empty) to hold sessions in memory, or unset IMMUTABLE.",
);
}
// And check the property itself, not just the intention to have it. Setting
// the variable while forgetting `--read-only` is the easy mistake, and it
// leaves an instance claiming a guarantee it does not have.
const probe = resolve(root, ".immutable-probe");
let writable = false;
try {
writeFileSync(probe, "");
writable = true;
unlinkSync(probe);
} catch {
/* EROFS, or EACCES on a root we do not own: either way, not writable by us */
}
if (writable) {
throw new Error(
`IMMUTABLE is set, but ${root} is writable. Run the container with --read-only (and --tmpfs /tmp), or unset IMMUTABLE.`,
);
}
}
if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url)));
/**
* Settings an installation decides, rather than each reader.
*
* A school turning on "warn about outside senders" for three thousand pupils
* cannot ask three thousand pupils to turn it on -- issue #207. Two sections,
* which are two different powers:
*
* - `defaults` seed an account that has never had settings of its own. The
* reader can change any of them afterwards; they are a starting point, not a
* rule.
* - `enforced` are applied on every load and cannot be changed here at all. The
* controls stay visible and go dead, which the issue asked for by name: a
* missing control confuses somebody who has used ihasmail elsewhere.
* - `changes` are applied once each, to everybody, including accounts that
* already exist -- and can be changed back afterwards. Each carries its own
* `version`, which is how an account remembers the ones it has had. The
* reporter's own analogy is a schema migration and this is that shape.
*
* Read from a file or straight from the environment, because ihasmail's own
* production runs read-only with no volume -- an installation that cannot mount
* a file can still set a variable.
*/
function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Record<string, unknown>; changes: Array<{ version: string; settings: Record<string, unknown> }> } {
const parse = (raw: string, where: string): Record<string, unknown> => {
try {
const v = JSON.parse(raw) as unknown;
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
return v as Record<string, unknown>;
} catch (err) {
/* Loud, and fatal. A policy that silently did not apply would look like
the feature not working, and the admin would have no way to tell. */
throw new Error(`Invalid ${where}: ${(err as Error).message}`);
}
};
/**
* A change list, checked rather than trusted.
*
* Every entry needs a `version` that is unique within the file: it is what an
* account stores to say it has had this one, so a duplicate would make two
* changes indistinguishable and a missing one would apply for ever.
*/
const parseChanges = (v: unknown, where: string): Array<{ version: string; settings: Record<string, unknown> }> => {
if (v === undefined) return [];
if (!Array.isArray(v)) throw new Error(`Invalid ${where}: "changes" must be a list`);
const seen = new Set<string>();
return v.map((entry, i) => {
const e = entry as { version?: unknown; settings?: unknown };
const version = typeof e.version === "string" ? e.version.trim() : "";
if (!version) throw new Error(`Invalid ${where}: changes[${i}] has no "version"`);
if (seen.has(version)) throw new Error(`Invalid ${where}: two changes share the version "${version}"`);
seen.add(version);
if (!e.settings || typeof e.settings !== "object" || Array.isArray(e.settings)) {
throw new Error(`Invalid ${where}: changes[${i}] ("${version}") has no "settings" object`);
}
return { version, settings: e.settings as Record<string, unknown> };
});
};
const file = process.env.SETTINGS_POLICY_FILE;
if (file) {
if (!existsSync(file)) throw new Error(`SETTINGS_POLICY_FILE does not exist: ${file}`);
const whole = parse(readFileSync(file, "utf8"), `SETTINGS_POLICY_FILE (${file})`);
return {
defaults: (whole.defaults as Record<string, unknown>) ?? {},
enforced: (whole.enforced as Record<string, unknown>) ?? {},
changes: parseChanges(whole.changes, `SETTINGS_POLICY_FILE (${file})`),
};
}
return {
defaults: process.env.SETTINGS_DEFAULTS ? parse(process.env.SETTINGS_DEFAULTS, "SETTINGS_DEFAULTS") : {},
enforced: process.env.SETTINGS_ENFORCED ? parse(process.env.SETTINGS_ENFORCED, "SETTINGS_ENFORCED") : {},
changes: process.env.SETTINGS_CHANGES ? parseChanges(JSON.parse(process.env.SETTINGS_CHANGES), "SETTINGS_CHANGES") : [],
};
}
/**
* Which Stalwart a domain signs in to.
*
* `STALWART_URL` stays required and stays the default; this only adds domains
* that go somewhere else (#238). An installation that sets nothing behaves
* exactly as it always has.
*
* Read once at boot and never written, so it mounts read-only and costs
* nothing in immutability -- the same shape as the settings policy.
*
* Servers are deliberately **not** probed here. A mapping is a routing table,
* not a health check, and refusing to boot because one of five customers is
* having an outage would take the other four down with it. What happens when
* one is unreachable is a sign-in question, answered in #239.
*/
function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } {
const file = process.env.STALWART_SERVERS_FILE;
if (!file) return { urls: {}, adminUrls: {} };
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
let raw: unknown;
try {
raw = JSON.parse(readFileSync(file, "utf8"));
} catch (err) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`);
}
return parseStalwartServers(raw, file);
}
/** The servers file's contents, checked. Exported so the shipped example is tested by the parser that reads it. */
export function parseStalwartServers(raw: unknown, file: string): { urls: Record<string, string>; adminUrls: Record<string, string> } {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`);
}
const out: Record<string, string> = {};
const adminUrls: Record<string, string> = {};
for (const [rawDomain, rawValue] of Object.entries(raw as Record<string, unknown>)) {
/* The example file explains itself in a `_comment` key, and a copy of it
used to stop the server as "not a URL". No mail domain starts with an
underscore, so a key that does is a note, not a mapping. */
if (rawDomain.startsWith("_")) continue;
/* Lower-cased and stripped of the root dot, because that is how a domain
taken off a username will arrive and comparing them any other way means
a mapping that silently never matches. */
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalized`);
/* A domain's value is its server's URL, or an object that also names where
that server's own administration is: `{"url": …, "adminUrl": …}`. */
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
if (typeof value.url !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
out[domain] = httpUrl(value.url, `STALWART_SERVERS_FILE (${file}): "${domain}"`);
if (value.adminUrl !== undefined) {
if (typeof value.adminUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`);
adminUrls[domain] = httpUrl(value.adminUrl, `STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl`);
}
}
return { urls: out, adminUrls };
}
/** An absolute http(s) URL without its trailing slash, or a startup error naming where it came from. */
function httpUrl(raw: string, where: string): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`Invalid ${where}: not an absolute URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${where}: must be http or https`);
return raw.replace(/\/+$/, "");
}
const stalwartServers = readStalwartServers();
export const config = {
isProd,
appName: env("APP_NAME", "ihasmail"),
settingsPolicy: readSettingsPolicy(),
/**
* What this build calls itself: `2.16.57`. Set by the image build from
* `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of
* the build context and nothing in there could work it out. A dev checkout
* has git, so it falls back to asking; see `scripts/version.mjs`.
*/
version: resolveVersion(),
/**
* Where this instance's source can be had, shown to everyone who reaches it.
*
* The AGPL asks whoever *runs* a modified version to offer that version's
* source, not the one it was forked from -- so anyone deploying a patched
* ihasmail should point this at their own tree.
*/
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080),
/**
* The subpath this instance answers on: `/mail` for a proxy that maps
* `https://example.com/mail/` here, and `""` -- the default -- for the root.
*
* The prefix is expected to arrive intact: a proxy that strips it before
* forwarding should leave BASE_PATH unset, because then as far as this
* process is concerned it *is* at the root. What must match is the web
* build, which bakes the same variable into its asset URLs; a server that
* strips a prefix the bundle still asks for serves an app that cannot load
* its own scripts. `staticHandler` says so at the first request rather than
* leaving a blank page to explain itself.
*/
basePath: normalizeBasePath(process.env.BASE_PATH),
stalwartUrl,
stalwartServers: stalwartServers.urls,
/**
* Where an administrator reaches Stalwart's own administration, for the
* pointer on ihasmail's dashboard. Optional, and separate from STALWART_URL,
* which is how *this server* reaches Stalwart -- often an address no browser
* can open. Unset, the dashboard names Stalwart's administration without a
* link. A domain routed elsewhere takes its server's `adminUrl` instead.
*/
stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "",
stalwartAdminUrls: stalwartServers.adminUrls,
/**
* Say that an Enterprise-only section is Enterprise-only even on an
* Enterprise server. Off, as a real installation wants it; the public demo
* turns it on, because it reports Enterprise to show those sections and
* should not suggest they come without the license.
*/
showEnterpriseNotices: bool("SHOW_ENTERPRISE_NOTICES", false),
appSecret,
trustProxy: bool("TRUST_PROXY", true),
/**
* Peers whose X-Forwarded-* headers are believed. Empty falls back to
* loopback and the private ranges, which covers the usual reverse proxy on
* the same host or Docker network. A peer outside this is attributed by its
* socket address whatever it claims.
*/
trustedProxies: (process.env.TRUSTED_PROXIES ?? "").split(",").map((s) => s.trim()).filter(Boolean),
/** "auto" = Secure when the request arrived over https; "1"/"0" to force. */
secureCookies: (process.env.SECURE_COOKIES ?? "auto").toLowerCase(),
sessionTtl: int("SESSION_TTL", 12 * 60 * 60),
sessionRememberTtl: int("SESSION_REMEMBER_TTL", 30 * 24 * 60 * 60),
sessionFile,
/** True when this instance has asserted, and verified, that it is immutable. */
immutable,
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
imageProxy: bool("IMAGE_PROXY", true),
/*
* Whether ihasmail offers administration to accounts whose Stalwart role
* allows it. Off means off: no menu, no permissions sent to the browser, and
* the JMAP proxy refuses registry methods beyond the account's own -- see
* adminGate.ts. Stalwart's own interface is unaffected either way.
*/
administration: bool("ADMINISTRATION", true),
cookieName: env("COOKIE_NAME", "ihm_session"),
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
/*
* Requests per minute one session may make on the data path -- JMAP, blobs,
* the image and calendar proxies. The proxy is one Node process and saturates
* a core at roughly 2,000 operations a second, so without this a single
* signed-in user can deny service to everyone else. 1,200 a minute is twenty
* a second sustained: well above what a busy tab does, and an order of
* magnitude below where one tab starts to hurt the rest. 0 disables it.
*/
apiRateLimit: int("API_RATE_LIMIT", 1200),
/* Whether JMAP responses are gzipped. Measured: see the bake-off rerun. */
compressJmap: process.env.COMPRESS_JMAP !== "0",
/*
* How push reaches the browser. "relay" holds one upstream stream per tab
* (today's behavior). "subscribe" registers one JMAP PushSubscription per
* account and fans Stalwart's POSTs out to that account's tabs, holding no
* upstream connection at all -- see push.ts. It needs PUSH_URL: the https
* origin Stalwart can reach ihasmail at, with a certificate it trusts.
* An account that cannot be verified stays on the relay.
*/
pushMode: (process.env.PUSH_MODE === "relay" ? "relay" : "subscribe") as "relay" | "subscribe",
pushUrl: process.env.PUSH_URL || "",
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */
followAdvertisedUrls: process.env.STALWART_FOLLOW_ADVERTISED_URLS === "1",
};
export type Config = typeof config;
Binary file not shown.
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "https://default.example";
const { upstreamFor } = await import("./upstream.js");
const { config } = await import("./config.js");
/**
* Which Stalwart a username goes to (#238).
*
* `STALWART_URL` is required and is the default. The mapping only adds domains
* that go elsewhere, so an installation with no mapping behaves exactly as it
* always has -- which is what these first cases pin.
*/
test("with no mapping at all, everything goes to the default", () => {
assert.deepEqual(config.stalwartServers, {});
assert.equal(upstreamFor("[email protected]"), "https://default.example");
assert.equal(upstreamFor("[email protected]"), "https://default.example");
});
test("a bare username has no domain to map, so it goes to the default", () => {
// Stalwart accepts a login with no domain at all.
assert.equal(upstreamFor("demo"), "https://default.example");
assert.equal(upstreamFor(""), "https://default.example");
});
test("a mapped domain goes to its own server", () => {
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
try {
assert.equal(upstreamFor("[email protected]"), "https://mail.mapped.test");
} finally {
delete config.stalwartServers["mapped.test"];
}
});
test("an unmapped domain still goes to the default while others are mapped", () => {
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
try {
assert.equal(upstreamFor("[email protected]"), "https://default.example");
} finally {
delete config.stalwartServers["mapped.test"];
}
});
test("the domain is matched however it was typed", () => {
// Keys are normalized on load; the username has to be normalized the same
// way or a mapping silently never matches.
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
try {
assert.equal(upstreamFor("[email protected]"), "https://mail.mapped.test");
assert.equal(upstreamFor("[email protected]."), "https://mail.mapped.test", "root dot");
assert.equal(upstreamFor("someone@ mapped.test "), "https://mail.mapped.test", "stray spaces");
} finally {
delete config.stalwartServers["mapped.test"];
}
});
test("an address with an @ in the local part maps on the last one", () => {
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
try {
assert.equal(upstreamFor('"odd@name"@mapped.test'), "https://mail.mapped.test");
} finally {
delete config.stalwartServers["mapped.test"];
}
});
+62
View File
@@ -0,0 +1,62 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.APP_SECRET = "test-secret-for-ics-proxy";
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
/**
* Subscribing to a calendar makes the server fetch a URL a stranger published,
* which is the second time this app knocks on a door somebody else chose. It
* goes through the same guard as the first — these tests are about that guard
* being reached, and about `webcal:` not being a way around it.
*/
test("a calendar URL is refused before any connection when it points somewhere private", async () => {
for (const url of [
"http://127.0.0.1/calendar.ics",
"http://169.254.169.254/latest/meta-data/", // cloud metadata
"http://[::1]/calendar.ics",
"http://10.0.0.1/c.ics",
"https://192.168.1.1/c.ics",
]) {
const got = await safeFetch(url, 500);
assert.equal(got, "forbidden_target", url);
}
});
test("webcal: is treated as https rather than waved through", async () => {
// Every subscription URL people are given is a webcal: one. It has to be
// understood, and it must not be a way past the address check.
const got = await safeFetch("webcal://127.0.0.1/calendar.ics", 500);
assert.equal(got, "forbidden_target");
});
test("schemes that are not http, https or webcal are refused", async () => {
for (const url of ["file:///etc/passwd", "ftp://example.com/c.ics", "gopher://example.com", "data:text/calendar,BEGIN:VCALENDAR"]) {
const got = await safeFetch(url, 500);
assert.equal(got, "bad_scheme", url);
}
});
test("a URL carrying credentials is refused", async () => {
// Credentials in a subscription URL would be sent by the server on the
// reader's behalf to a host the reader may not have looked at.
assert.equal(await safeFetch("http://user:[email protected]/c.ics", 500), "bad_url");
});
test("nonsense is refused rather than guessed at", async () => {
for (const url of ["", "not a url", "://missing-scheme"]) {
assert.equal(await safeFetch(url, 500), "bad_url", JSON.stringify(url));
}
});
test("each refusal has a status that says which kind it was", () => {
assert.equal(safeFetchStatus("forbidden_target"), 403);
assert.equal(safeFetchStatus("bad_scheme"), 400);
assert.equal(safeFetchStatus("bad_url"), 400);
assert.equal(safeFetchStatus("bad_redirect"), 400);
assert.equal(safeFetchStatus("dns_failure"), 502);
assert.equal(safeFetchStatus("fetch_failed"), 502);
});

Some files were not shown because too many files have changed in this diff Show More