Recipient suggestions:
- the words typed match in any order, each one the start of a word in the
name, a nickname, the organization or the address: "jane smi" finds
"Smith, Jane", and "globex" finds the people at Globex;
- someone written to lately ranks a little above an equal match;
- an address already in To, Cc or Bcc is no longer offered in the other
two fields.
Pasting:
- a single web or mailto address pasted over selected words makes those
words the link, instead of replacing them with the address;
- a pasted or dropped image over 10 MB goes in as an attachment rather
than inline, where it would swell every reply.
No new strings.
On a phone or a narrow list, rows are two lines, and labels went on a third
line of their own. A row is a fixed height at each density, and that third
line fit it only at Comfortable: at Cozy, the default, the labels were cut
off at the bottom, and at Compact they sat outside the row.
They now sit on the sender's line, after the name, which has room to spare,
so the row's height is unchanged at every density. When the line runs short
the name gives way first, then each label truncates; the date stays.
Reported in coffey-labs/ihasmail#35.
(cherry picked from commit 22490d8fe0190d85d8039a977435245b0d7da3cc)
A release event runs the workflow as it was at the tag, so re-running a
failed announcement repeats the failure even after main is fixed. A manual
run takes the tag and uses main's workflow; the action already accepts it.
The repository moved from inbuxa/ihasmail-inbuxa to inbuxa/inbuxa-webmail,
matching inbuxa-server and inbuxa-admin. Point the source links, the image
name and the package link at the new name. The OAuth client id stays
ihasmail-inbuxa, since that is what the server registers.
The Gitea registry stays authoritative; GHCR becomes a copy of it, the way
the GitHub repository is a copy of the Gitea one. After the tag build has
pushed the release image to the registry, a new ghcr job copies it to
ghcr.io under the same version tag and :latest with `imagetools create` --
a copy, not a rebuild, so the digest on GHCR is the digest on the registry.
Anything still pulling the old ghcr.io name, including the TrueNAS app
submission, keeps receiving releases. The job uses the run's own token and is
left out of the status reported to Gitea, so a GHCR problem cannot fail a
release.
The mirror carries tags to GitHub but not releases, so the replica's
Releases page -- and anyone watching the repository there -- stopped at the
last release made on GitHub. After the tag build has published, a new
github-release job copies the tag's Gitea release to a GitHub release: the
same notes, with PR and issue numbers rewritten to Gitea links, the same
files, and a line pointing back to the Gitea release.
It uses the run's own token and is left out of the status reported to
Gitea, so it cannot fail a release. With no Gitea release for the tag it
does nothing.
Commit statuses belong to the commit, not the tag. Upstream v* tags and
inbuxa-v* tags can sit on the same commit, so Gitea's github job, waiting
for "github/ci (tag)", could read the other tag's older result and move on
before this tag's build finished. A tag's status is now "github/ci (tag
<name>)" on both sides.
The per-architecture digest artifacts are also overwritable and kept for a
week, so re-running a build, or publish on its own, still works.
The github job only polls Gitea for GitHub's commit status, but it holds a
runner slot for as long as the GitHub build takes -- the better part of an
hour for a cold build. On the shared build runners a handful of those
could take every slot and stall real work, so it now runs on the `wait`
label: a runner of its own, with many slots, no docker socket and a small
CPU and memory cap.
The mirror can push one commit twice in quick succession. GitHub then
starts two runs and cancels the older, and that run's report job posted
"failure" for the commit. Gitea's github job, seeing the newest status,
failed the check while the surviving run was still building and later
passed.
A cancelled run now posts nothing and leaves the result to the run that
superseded it. A real failure still reports failure.
This repository is now push-mirrored to GitHub, where issues and pull
requests would never reach the maintainers. A note under the title says
where development happens, and sends issues to git.coffeylabs.org and
discussions to community.coffeylabs.org.
Gitea stays the source of truth and push-mirrors this repository to
GitHub. The org variable BUILD_ON, set on both forges, picks where the
heavy work runs:
- unset: nothing changes. Gitea's jobs run as before and every job in
the GitHub workflow is skipped.
- github: Gitea skips its test, build and publish jobs. GitHub Actions
runs them on hosted runners, arm64 natively rather than under QEMU,
publishes to the same Gitea registry, and posts a commit status back
to Gitea. A new `github` job in Gitea's ci.yml waits for that status
and passes or fails with it, so the Gitea run still decides a PR.
Announcing and releasing stay on Gitea whatever BUILD_ON says.
The GitHub-era workflows go: cleanup.yml pruned GHCR, release.yml was a
second weekly scheduler, and publish.yml pushed to GHCR. Their work is
in the new .github/workflows/ci.yml or stays on Gitea. dependabot.yml
goes too: its pull request branches would exist only on GitHub, and
every mirror sync would delete them.
Creating an app password asks for the account password. A session
holding a token has no password to compare with, so it sent the typed
one to the mail server as HTTP Basic on the JMAP session. INBUXA's
server now takes no password outside DAV (contract C-23), so that check
would always fail.
It now asks the server's sign-in endpoint, the one its own sign-in page
posts to, as this client, to its registered redirect URI, with a PKCE
challenge whose verifier is thrown away so the code can never be
exchanged. "Two-factor code needed" counts as confirmed: the server
says so only after the password matched, so accounts with two-factor
sign-in now pass where the Basic check failed them.
The mock answers /api/auth like the server and can refuse Basic on
JMAP; the app-password test turns that on, and fails on the old check.
The webmail half of inbuxa's DLP (dlp-and-mail-flow-rules spec, §2.5,
§4):
- A send the server's DLP rules refuse (inbuxa:dlpWarning or
inbuxa:dlpBlocked) comes back to the composer with the rules' notices
instead of a generic "Send failed". A warning offers "Send anyway…",
which asks for a reason and sends again with inbuxa:dlpOverride; the
server records the reason. A block can only be answered by changing
the message.
- A message DLP held for review says so on sending ("Held for review:
it's sent once a reviewer releases it"), from the submission's
inbuxa:held.
- Tests: the override travels with the submission only when there's a
reason; refusals are told apart from other errors.
Nine new English strings (the notice labels, the prompt, the toasts);
the other catalogs fall back to English until translated.
inbuxa can now switch IMAP, POP3 and ManageSieve off one at a time,
server-wide and per organization, and the session lists what is still
allowed for the account (legacyAllowed). Where the webmail said
"legacy protocols are off", it now also covers the case where only
some are:
- Security & sessions, above app passwords: "Your organization has
turned off POP3 for mail apps. Mail apps that use it can't connect
to this account; others still can."
- The Administration dashboard: "Some legacy mail protocols are off for
your organization: POP3."
- An organization's sheet in Administration: its switch stays the
all-or-nothing one; when only some are off it names them and points
to the console, where they're switched one at a time, and "Turn
legacy protocols back on" turns them all back on.
With every protocol off, the existing wording shows, as before. From a
server that doesn't send legacyAllowed nothing new appears.
3 new strings in all nine catalogs, unreviewed.
Tested: unit tests for reading the session and a tenant's switches;
the existing tests updated for the new field; typecheck; the whole
suite (1475 tests); and in headless Chrome against a local server with
POP3 off, where Security & sessions showed the new note.
Deleting a person's account is the console's now, beside locking it and
legal holds: the console asks why, for the audit log, and says when a
hold keeps the data. Where Delete was, the account's page says so and
links to the account in the console when the server names one. Groups,
lists, domains and tenants keep their delete here.
2 new strings in all nine catalogs, unreviewed.
Switching to a locked account handed to the reader moved only the mail.
Now calendar, contacts and files follow it as well, through a new
viewAccountFor that the three stores use for what they show. Settings,
signatures and push keep ownAccountFor, so nothing of the reader's is
ever written into the locked account (inbuxa AL-7).
The picker sorted folders A-Z by path, with Inbox first, so a folder
dragged into place in the sidebar turned up somewhere else when moving
mail. It now walks the tree in compareFolders order, the sidebar's
order with every folder expanded: Inbox, then the saved order, then
the special folders, then A-Z, with subfolders under their parent.
treeOrder lives beside compareFolders. A folder the walk from the top
cannot reach is appended rather than dropped, so it stays pickable as
it was before.
Closes#1
(cherry picked from commit ea03406646062359f74e16ad8a8aed074b4dc409)
Name the reviewer in both Dutch catalogs, FEATURES and ROADMAP, under both of his handles, and record that his wording stands.
(cherry picked from commit aaa86e96d66e2431a8c98467712a977d4035bf76)
Michael (mbjboon-netizen) sent the final corrections for nl.ts and the Dutch
permission labels and signed the language off, so Nederlands no longer
carries the Beta flag in the picker.
The main catalog changes 52 values, mostly "regels" -> "filterregels" and
"post" -> "e-mail(s)". The permission headings move from compound nouns
("Accountbeheer") to verb phrases ("Accounts beheren"), and the reviewer's
note on that is kept in the file. No keys were added or removed, and every
placeholder is intact.
One entry is kept as it was: "It {damage}, ..." stays "Het {damage}, ...".
{damage} is filled with a verb phrase ("stops in the middle of a line"), so
the added "is" would have doubled the verb.
README, FEATURES, ROADMAP and KNOWN-ISSUES now say Dutch has been reviewed
and the other eight have not.
(cherry picked from commit e30fd73d7dbb1463859efbc4048f680d21d64c56)
Folder subscriptions are the reader's own and they have none in an
account handed to them, so only Inbox showed. Every folder shows while a
locked account is in view, and Hide from list is gone there.
When the server hands a locked account to the reader (urn:inbuxa:jmap
delegation), the account popover offers it. Only mail follows the switch;
the reader's own settings, push and notifications stay theirs. A red bar,
a red wordmark with a padlock and the tab title say which account is in
view. Read delegates can't change anything, organize delegates can't
delete, and writing needs send-as. A delegation taken away drops back to
the reader's own mail.
14 new strings in all nine catalogs, unreviewed (inbuxa AL-7, AL-8).
The logo, favicons and app icons are served from public/img under fixed
names with a browser cache of hours, and the service worker fetches them
through that cache. After the mark changed on 2026-09-27, returning
visitors kept the old cat until their copies expired, and the favicon
and an installed app's icon hold on longer still.
Every URL that names one now carries ?v=BRAND_V (src/lib/brand.ts,
brandImage()): the header, sign-in, About, the mail empty state, the
notification icons, index.html's favicon links, the manifest's icons
and the service worker's shell and notification icons. Date-stamped,
never a counter, for the sites' ASSET_V reason; the three static files
carry the value written out, and the comment says to keep them in step.
The webmail showed ihasmail's cat-and-envelope as inbuxa's mark. The new
mark keeps the family's face, paws and colors, over a server with a bay
for each piece of the suite: the letter (webmail), a prompt (console),
status lights (server).
- img/inbuxa-mark.png (header, sign-in, About) and img/logo.png (the
mail empty state and the custom-name fallback).
- favicon.ico, favicon-64, apple-touch-icon (opaque white, as before),
icon-192/512, and icon-maskable, now on an opaque ground with the
mark inside the safe circle.
- Login.tsx: 120x126, the new mark's proportions; 120x143 would have
stretched it. Every other use sizes by one dimension.
- The service worker fetches images network-first, so installed copies
pick the new ones up without a cache version bump.
announce.yml runs coffey-labs/actions discourse-release on every published
release, posting it to this project's Announcements category on
community.coffeylabs.org. The release workflow also announces
from its own job, since a release made with the job token fires no
'on: release' workflow in Gitea.
The lead now says what inbuxa is: a mail server, its administration
console and this webmail, installed together under the AGPL, with the
name set apart in the brand teal. A new "The suite" table lists the
mail server, the console (linked, for sessions that may administer),
this webmail's version and inbuxa.org.
4 new strings in all 9 catalogues; the old webmail-only lead is dropped
from them, since nothing looks it up any more.
dns.reverse came back empty inside the image while the resolver answered
the PTR, so About showed only the address. Ask for the PTR record of the
in-addr.arpa / ip6.arpa name directly.
Settings > About shows which webmail node answered (NODE_NAME, else the
container hostname) and which inbuxa node it talks to: the address the
server's name resolves to from the webmail, named by its PTR record. The
server only tells administrators its node name, so the webmail works it
out itself. Fetched from /api/about/nodes on every visit, cached for a
minute server-side, for troubleshooting a cluster.
Fork-only: upstream ihasmail runs one webmail against one server.
4 new strings, translated in all 9 catalogues.
When inbuxa-server's AI spam classification is on, it records the model's
answer in an X-Spam-LLM header: a tag (LLM_<category>[_<confidence>]) and,
in parentheses, the model's explanation. The full message now asks for it,
and where it's there:
- the message details show "Language model's opinion" beside the spam
filter's own working, with category, confidence and explanation;
- a message in Junk carries a banner saying the same.
Both say it's one of several signals the spam filter weighed, never the
reason on its own, as the server's spec requires. The explanation is model
output and is only ever rendered as text. Nothing shows without the header,
so a server without the feature, or with it off, looks as before.
Translations: two new strings, "Language model's opinion" and "One of
several signals the spam filter weighed", in all eight catalogues (16
entries). Category and confidence come from the server and aren't
translated.
inbuxa-server renames the identifiers that carried the upstream name (its
SPEC.md §2.4). Upstream's capability for the registry (x:) objects is now
urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap, which is
unchanged. There's no alias, so this lands with the server change and
deploys with it. The mock advertises the new name too. No user-visible
strings change.
The browser tab, and anything that takes its name from the document title,
read INBUXA. The manifest, the server's app name and the sign-in card all
have it lowercase; the title was the one place left in caps.
Prod's APP_NAME override was set to inbuxa at the same time; the code
default already was.
The mock inbox showed a sender called "Stalwart Labs" at [email protected],
a "Welcome to Stalwart!" subject, a link to stalw.art in the sample HTML
and a start-up banner tagged [mock-stalwart]. None of that belongs in this
fork, and it turns up in any screenshot taken from the mock.
Sample senders and subjects now name inbuxa, the sample link points at
inbuxa.org, and the banner says [mock-server].
The title the tab falls back to before the session names the app was the one
user-visible string the brand pass missed; setBaseTitle overwrites it as soon
as the session arrives, so it shows only for that first moment.
The brand is lowercase inbuxa. This changes what the app calls itself by
default, the wordmark's accessible name, the sign-in card's version line, the
installed app's name in the manifest, and the four translated strings that
name the console or the mail server.
Those four are source strings, so their catalog keys changed with them in all
nine languages; the translations keep their text with the name corrected. No
key was left behind, and no language falls back on more strings than before:
1643/1662 translated, 19 falling back, in each of the nine, unchanged.
Code identifiers, capability URNs, env var names and comments are untouched.
A tag named inbuxa-v<version> (the tagged commit's own version from
scripts/version.mjs, '+' as '-') now builds a linux/amd64 + linux/arm64
image at <REGISTRY>/inbuxa/ihasmail-inbuxa, tagged with the version and
latest, links the package to the repository and creates the release.
The inbuxa- prefix keeps upstream ihasmail's v* tags, which this
repository carries on shared commits, from ever publishing under the
INBUXA name. The tag must name its commit's version and the commit must
be on main. No schedule yet: releases are cut by hand.
GitHub went dark with the account suspension on 2026-09-20 and GitLab was
retired on 2026-09-22, so links to either no longer resolve. Repository,
file, release and download links now point at git.coffeylabs.org, images
at registry.coffeylabs.org, and old GitHub issue and pull request links at
coffey-labs/ihasmail-github-archive, whose numbers match GitHub's.
Both runners carry `light` (host1, and host2 over the wg-hosts link), so
these jobs run on whichever host is free. Jobs that mount the docker socket
keep `runs-on: docker`, which only host1 has.
INBUXA's tenant switch (legacy-protocols LP-9 to LP-18) in the
administration. Each tenant's sheet gains "Legacy mail apps": whether IMAP,
POP3, ManageSieve and sending from mail apps are on or off on the tenant's
domains, and the switch.
Nobody turns it off by accident. "Turn off legacy protocols…" first shows
who would notice -- every account in the tenant that signed in with a
legacy mail app in the last 30 days, with the protocols and when (LP-15) --
and the statement of what it means, "for everyone in {tenant}" (LP-16),
then asks for the phrase "turn off legacy mail", matched exactly (LP-17).
Turning it back on is one click; the server refuses while it has legacy
protocols off for everyone, and its words are shown. A tenant's switch
closes no port, so the statement names none.
It needs the domain permissions the server checks for the switch; with
read-only access the state shows and the buttons don't. On a server that
isn't INBUXA, or is older, the section isn't there.
The dashboard says so while legacy mail is off for the signed-in
administrator's organization (LP-18), from the same session flag as
Settings › Security's line.
Every new string in all nine languages, the register each catalog uses, and
the count in each language's plural forms.
When the mail server has turned off legacy mail protocols -- IMAP, POP3,
ManageSieve and sending from mail apps -- for this account, whether for
the whole server or for the account's organization, Settings › Security
says so at the top of App passwords, the section people come to when a
phone won't connect:
Your organization allows only {app} and JMAP apps, so phone and desktop
mail apps can't connect to this account.
This is INBUXA's legacy-protocols LP-19. The server reports it per
account as legacyProtocols on the urn:inbuxa:jmap account capability
(contract C-1); anything short of a plain "disabled" -- an older server,
another server, no session yet -- reads as on, so the line never appears
where it isn't true.
The app's name comes from {app}, as everywhere else. Translated into all
nine languages, in the register each catalog already uses.
Every tag in this repository is one of ihasmail's own upstream tags, the
same commits, and at those tags publish.yml pushed to ihasmail's image. A
tag-driven publish here therefore ships plain ihasmail under the INBUXA
name as soon as upstream tags reach this project. That happened once and
the image was deleted.
The version and publish jobs go, with the publish stage and the IMAGE
variable only they used. Tests and the Docker build check are unchanged.
The header says why, so the job is not ported back from publish.yml.
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.
Ports ci.yml and publish.yml after the GitHub account was suspended. The
workflow here is identical to the one upstream in ihasmail, so this is the
same pipeline: tests as the image's unprivileged node user, git installed
for the version check, and a tag-driven multi-arch publish under QEMU.
The job environment differences are explained inline -- they are all cases
where a container is not a workstation, not changes to what is tested. No
test was modified.
The Actions workflows stay in the tree as the reference.
Two things inherited from public ihasmail that became live the moment this
repository went public.
publish.yml pushed ghcr.io/coffey-labs/ihasmail -- the image every public
ihasmail install pulls. A release here would have published INBUXA's webmail
over it, which is the exact confusion SPEC 5 exists to prevent. It now
publishes ghcr.io/inbuxa/ihasmail-inbuxa.
release.yml cuts a release every Monday at 09:17 UTC, which public ihasmail
wants because it has users expecting one. This fork has none yet, and the
release triggers the publish, so on Monday it would have shipped an image
and mailed everyone watching about a product that has not shipped. Scheduled
runs now stop at the first job; a release can still be cut by hand, and the
weekly one comes back by removing one line.
Upstream's {app} placeholder (#406) replaces most of the fork's own
renamed strings: the user menu, the About heading and its version line
now say INBUXA because APP_NAME does, not because the fork wrote it in.
Kept from the fork: inbuxa.org rather than ihasmail.org, no Documentation
entry until INBUXA has its own, the INBUXA mark and wordmark, the "Built
on ihasmail" credit, and the About note that says nothing about the
server software. DEFAULT_APP_NAME stays INBUXA.
The credit's placeholder is {project} now, so the name of the project is
not spelled inside a key that upstream's new test reads as a hard-coded
app name.
- Reorder folders by dragging, with special folders first (#402, #405).
- Open the composer full screen, as a setting (#401, #404).
- Dutch translation update (#403).
FEATURES.md stays deleted here, as in bb25355.
- The sign-in footer names the build "INBUXA webmail" and links to
inbuxa.org, next to its AGPL source link.
- About gives the INBUXA webmail version, and credits ihasmail on a line
of its own.
- The user menu's "About ihasmail" is "About INBUXA", to inbuxa.org. The
Documentation entry pointed at ihasmail's docs; it's gone until INBUXA
has documentation of its own.
- The startup log says "mail server:" rather than naming the server
software.
"About INBUXA" and "Built on {ihasmail}" are translated in all nine
catalogues.
Every build writes the exact source it was built from, uncommitted work and
new files included, as source.tar.gz next to the app, named after that tree.
Docker builds, which have no git, pack the build context and name it by a
hash of its files. The sign-in page and Settings > About link to it instead of
a repository that can drift.
What users, operators and packagers see no longer names the upstream server:
- interface text, in all nine catalogues, with a token-session line for
Security;
- server messages;
- the settings, now MAIL_SERVER_URL, MAIL_SERVERS_FILE, ADMIN_URL and
MAIL_SERVER_FOLLOW_ADVERTISED_URLS, and mail-servers.example.json;
- the Tenants notice, which is gone;
- the README, CONTRIBUTING and SECURITY.
ihasmail's own FEATURES, KNOWN-ISSUES and ROADMAP stay with public ihasmail,
and INBUXA.md is folded into the README.
INBUXA is a product suite and ihasmail an independent product, so INBUXA's
webmail says INBUXA: the sign-in page, header, page title, installed-app name
and About page. The wordmark is drawn in the current text color. ihasmail's
version and AGPL source line stay as its credit. Two new strings, in all nine
catalogues; three Stalwart-only ones are no longer used.
The server's own page asks for the username, so with a single server the
sign-in page keeps only the own-device choice. With several servers the
address still comes first, since its domain picks the server. One new string,
in all nine catalogues.
Contract C-8 and C-10: with OAUTH_CLIENT_SECRET set, sign-in goes through the
server's page and the session keeps sealed tokens, renewed before they expire,
instead of a password. Push keeps a credential that renews itself. A password
change signs the session out, since the server revokes its tokens. The mock
answers OAuth for tests and development. Eleven new strings, in all nine
catalogues.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.