Compare commits

..
89 Commits
Author SHA1 Message Date
jcoffey-dev c2f13d6a1a Merge pull request 'ci: publish tagged releases as INBUXA's own image' (#9) from ci/inbuxa-release into main
ci / version (push) Successful in 1m24s
ci / node (push) Successful in 2m33s
ci / docker-build (push) Skipped
ci / publish (push) Successful in 5m41s
2026-09-22 16:58:24 +00:00
jcoffey-dev afb39fac20 ci: publish tagged releases as INBUXA's own image
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m15s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 28s
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.
2026-09-22 09:56:09 -07:00
jcoffey-dev b514dab62a Merge pull request 'Point links at the new git host' (#8) from fix/links-after-move into main
ci / node (push) Successful in 3m15s
ci / docker-build (push) Successful in 58s
2026-09-22 16:09:58 +00:00
jcoffey-dev 2d7d8952ab Point links at the new git host
ci / docker-build (pull_request) Successful in 1m13s
ci / node (pull_request) Successful in 4m42s
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.
2026-09-22 09:03:00 -07:00
jcoffey-dev e752d08ebc Merge pull request 'ci: run socket-free jobs on the light label' (#7) from ci/light-runner into main
ci / node (push) Successful in 2m28s
ci / docker-build (push) Successful in 40s
2026-09-22 13:58:10 +00:00
jcoffey-dev dd955a939e ci: run socket-free jobs on the light label
ci / docker-build (pull_request) Successful in 1m31s
ci / node (pull_request) Successful in 2m28s
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.
2026-09-22 06:45:41 -07:00
jcoffey-dev 26cf45f502 Merge branch 'ci/gitea-actions' into 'main'
ci / node (push) Successful in 5m11s
ci / docker-build (push) Successful in 1m8s
ci: add Gitea Actions workflow

See merge request inbuxa/ihasmail-inbuxa!6
2026-09-22 00:03:04 -07:00
jcoffey-dev 7469598178 ci: add Gitea Actions workflows ported from .gitlab-ci.yml
ci / node (pull_request) Successful in 2m25s
ci / docker-build (pull_request) Successful in 3m28s
2026-09-21 22:49:35 -07:00
jcoffey-dev f0a92deb08 Merge branch 'feat/tenant-legacy-switch' into 'main'
A tenant's administrator can turn legacy mail apps off for the organization

See merge request inbuxa/ihasmail-inbuxa!5
2026-09-21 14:40:47 -07:00
jcoffey-dev 8abf3a96aa A tenant's administrator can turn legacy mail apps off for the organization
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.
2026-09-21 13:54:24 -07:00
jcoffey-dev acb93a90a6 Merge branch 'feat/legacy-protocols-notice' into 'main'
Security says why a mail app won't connect, when legacy protocols are off

See merge request inbuxa/ihasmail-inbuxa!4
2026-09-21 13:50:32 -07:00
jcoffey-dev 4cac9088dd Security says why a mail app won't connect, when legacy protocols are off
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.
2026-09-21 13:44:50 -07:00
jcoffey-dev 1cde6f3032 Merge branch 'ci/no-publish' into 'main'
Drop the publish job

See merge request inbuxa/ihasmail-inbuxa!3
2026-09-20 23:01:24 -07:00
jcoffey-dev e5f590978e Drop the publish job
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.
2026-09-20 22:58:38 -07:00
jcoffey-dev cf93a1697f Merge branch 'ci/image-version' into 'main'
Build published images with the version they report

See merge request inbuxa/ihasmail-inbuxa!2
2026-09-20 22:27:17 -07:00
jcoffey-dev 17f0552453 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:32 -07:00
jcoffey-dev 461129c5d6 Merge branch 'ci/gitlab-pipeline' into 'main'
Run CI on the self-hosted GitLab

See merge request inbuxa/ihasmail-inbuxa!1
2026-09-20 20:36:11 -07:00
jcoffey-dev 95f7f8008e Run CI on the self-hosted GitLab
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.
2026-09-20 20:15:24 -07:00
jcoffey 097d08900c Start the weekly release (#3)
The schedule has been off since the fork was cut, with the check job
gated to workflow_dispatch so a release could only be cut by hand. The
reason was that a scheduled release publishes an image and mails
everyone watching the repository, and there was nothing yet to tell
them about.

INBUXA is releasing now, so the gate comes off: Mondays at 09:17 UTC,
and nothing on a quiet week, which is what the workflow already decides
for itself. Cutting one by hand still works and is unchanged.

This restores the file to public ihasmail's, byte for byte -- the gate
and the note explaining it were the only difference.

Two things worth knowing before the first one runs. GHCR creates a
package private the first time, even in a public repository, and no
workflow can change that: the first release will publish an image that
an anonymous `docker pull` refuses until the visibility is set by hand
under the package's settings. And GitHub disables scheduled workflows
in a repository with no activity for 60 days, so a silent stop later is
worth checking for before assuming the file is broken.
2026-09-20 16:07:56 -07:00
jcoffey de120ba7ca The source offer is a link to this fork, not a tarball in the image (#2)
INBUXA's webmail built its own source into every image: the whole tree,
web and server, packed as dist/source.tar.gz with an identity string
beside the link naming the exact tree it came from. The sign-in page and
Settings > About offered that download.

It answered the AGPL precisely -- the source of *this* build, uncommitted
work and all -- but it paid for that precision by carrying 2.5 MB of
source into production on every deploy, to a repository that is public
and already has it. The fork is at github.com/inbuxa/ihasmail-inbuxa;
the version shown directly above the link already names the commit the
build came from, so the link and the version together say the same
thing the archive said.

Both links now go there, through the mechanism upstream ihasmail already
has and this fork had replaced: the server's SOURCE_URL, read from
/api/config on the sign-in page and from the session in About, with
web/src/lib/source.ts as the fallback before either answers. That
mechanism is better than a hardcoded URL for the deployer who patches
this tree -- they set SOURCE_URL and both links follow -- which is the
case the AGPL is actually about. The defaults in config.ts, the compose
file and .env.example move from the upstream repo to this one, since a
build from this tree is a modified ihasmail and its offer is ours.

Removed with it: scripts/source-archive.mjs and its type stub, the Vite
plugin that ran it, __SOURCE_ID__, and SOURCE_ARCHIVE/SOURCE_ID. The
build no longer shells out to git or tar, and nothing is written next
to the app.

Links to Coffey-Labs/ihasmail that are credit rather than a source
offer -- the README's "built on", the translation issue link -- are
left alone.

No new strings: "AGPL-3.0 source" is unchanged, and the About line keeps
its existing {source} placeholder, now filled with the host and path
instead of a file name.
2026-09-20 16:07:30 -07:00
jcoffey-dev 25763832f3 Publish to INBUXA's own image, and don't release on a schedule yet
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.
2026-09-20 00:01:02 -07:00
jcoffey a2ba7f5acd Remove development status from README
Removed status update indicating the project is in development.
2026-09-19 23:46:58 -07:00
jcoffey-dev fdcf27f3ea Merge public ihasmail: the app's name comes from APP_NAME everywhere
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.
2026-09-19 14:29:43 -07:00
jcoffey 9e3844e94a 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-dev f8119fafbf Merge public ihasmail: folder reordering, full-screen composer, Dutch update
- 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.
2026-09-19 14:11:50 -07:00
jcoffeyandClaude Opus 5 8a7ff5d42f 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

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 14:06:21 -07:00
jcoffeyandClaude Opus 5 c201d34377 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

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
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-dev cfa661de20 INBUXA's name and site where the webmail still said ihasmail
- 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.
2026-09-19 10:09:28 -07:00
jcoffey-dev cee4f74257 Source link opens in a new tab 2026-09-19 00:49:24 -07:00
jcoffey-dev bb25355c23 AGPL source offer and name cleanup
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.
2026-09-19 00:13:12 -07:00
jcoffey-dev 9d2de725c9 Merge public ihasmail: drag calendar events to another day in the week grid (#400) 2026-09-18 21:30:38 -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
jcoffey-dev 2e668edb51 Brand the webmail INBUXA: name, mark and wordmark
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.
2026-09-18 15:58:56 -07:00
jcoffey-dev cdd8fabff9 No address step before signing in when there's only one mail server
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.
2026-09-18 15:51:54 -07:00
jcoffey-dev 8857bdac30 Sign in on the mail server's own page (OAuth with PKCE), sessions hold tokens; tenants on every edition
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.
2026-09-18 15:30:37 -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
287 changed files with 9364 additions and 4991 deletions
+3
View File
@@ -3,4 +3,7 @@ node_modules
**/dist
.git
.env
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
.env.*
!.env.example
server/data
+14 -13
View File
@@ -1,8 +1,8 @@
# ---- 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
# Base URL of your mail server (scheme + host, no path). ihasmail discovers
# the JMAP session at <MAIL_SERVER_URL>/.well-known/jmap.
MAIL_SERVER_URL=https://mail.example.com
# Random secret used to derive encryption keys for persisted sessions.
# Generate with: openssl rand -base64 48
@@ -61,10 +61,10 @@ 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
# In-app administration, for accounts whose role on the mail server 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.
# the mail server's registry methods beyond an account's own password, app passwords
# and settings. INBUXA Admin is not affected.
ADMINISTRATION=1
# Branding
@@ -73,8 +73,9 @@ 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
# and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so
# the default is this fork.
SOURCE_URL=https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa
# ---- Settings this installation decides (all optional) ----
#
@@ -100,16 +101,16 @@ SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
# 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) ----
# ---- Several mail servers (optional) ----
#
# Choose the upstream by the domain someone signs in with. STALWART_URL above
# Choose the upstream by the domain someone signs in with. MAIL_SERVER_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
# elsewhere. See the shipped mail-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
# MAIL_SERVERS_FILE=/etc/ihasmail/servers.json
#
# An unlisted domain, or a username with no domain, goes to STALWART_URL. A
# An unlisted domain, or a username with no domain, goes to MAIL_SERVER_URL. A
# listed domain never falls back. Read once at startup: editing means a restart.
+179
View File
@@ -0,0 +1,179 @@
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
# this directory exists; .github/workflows stays as it was for GitHub.
#
# Releases are cut by pushing a tag named `inbuxa-v<version>`, where
# <version> is what scripts/version.mjs says for the tagged commit with the
# `+` turned into `-` (e.g. inbuxa-v2026.9.22-g1a2b3c4). The prefix matters:
# this repository carries upstream ihasmail's own `v...` tags, on commits it
# shares with upstream, and a publish keyed on `v*` would ship plain ihasmail
# under the INBUXA name the moment one arrived. Only `inbuxa-v` tags publish.
# A tag publishes only if it names its own commit's version and that commit is
# on main. There is no release schedule yet; tags are cut by hand.
# Every job runs in an image pinned by digest (tag in the trailing comment),
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
# instance resolves short `uses:` against itself, never GitHub, so nothing
# unreviewed can be pulled in. Read the comment for the version; the digest is
# what runs. Do not "simplify" one back to a bare tag.
#
# Jobs run on the runner's `ci-net` network and clone from Gitea's internal
# address, never through the Cloudflare-proxied public name, which caps
# request bodies at 100 MB.
name: ci
on:
push:
branches: [main]
tags: ['**']
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# -------------------------------------------------------------- test ------
node:
runs-on: light
container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
env:
NPM_CONFIG_CACHE: ${{ github.workspace }}/.npm
steps:
# version.test.ts shells out to git to resolve a build version, and the
# slim image ships without it; the checkout action installs it when it
# is missing, so it is there for the tests too. Full history, because
# the version is computed from it.
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
# 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.
- run: chown -R node:node "$GITHUB_WORKSPACE"
- run: su node -p -c "npm ci --ignore-scripts"
- run: su node -p -c "npm run typecheck"
- run: su node -p -c "npm test"
- run: su node -p -c "npm run build"
# ------------------------------------------------------------- build ------
# Proves the Dockerfile still builds on every change, without pushing. The
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. The
# Dockerfile builds everything itself; `needs` only keeps the order.
docker-build:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: [node]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
tag="ihasmail:ci-$(echo "$GITHUB_SHA" | cut -c1-8)"
docker build -t "$tag" .
docker image rm "$tag"
# ----------------------------------------------------------- release ------
# Only for `inbuxa-v` tags (see the top of this file). The tag has to name
# its own commit's version, so the image, the release and the About screen
# all agree, and the commit has to be on main, so a release never describes
# code that was not reviewed onto the default branch.
version:
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }}
runs-on: light
container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
outputs:
version: ${{ steps.v.outputs.VERSION }}
docker_tag: ${{ steps.v.outputs.DOCKER_TAG }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- id: v
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
V="$(node scripts/version.mjs)"
want="inbuxa-v${V/+/-}"
[ "$TAG" = "$want" ] || { echo "!! $TAG does not name this commit's version; expected $want"; exit 1; }
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|| { echo "!! $TAG is not on main"; exit 1; }
echo "VERSION=$V" >> "$GITHUB_OUTPUT"
echo "DOCKER_TAG=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "VERSION=$V DOCKER_TAG=${V/+/-}"
# Multi-arch image at <REGISTRY>/inbuxa/ihasmail-inbuxa, then the release.
# arm64 is built under QEMU on this amd64 host, which is slow but fine for
# a hand-cut release. PACKAGE_TOKEN (jcoffey-dev, write:package) logs in:
# the job's own token is refused by the container registry. The release is
# created last, so a release on the page always has its image behind it.
publish:
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }}
needs: [node, version]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
DOCKER_BUILDKIT: "1"
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
test -n "$REGISTRY" && test -n "$VERSION" && test -n "$DOCKER_TAG"
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
- run: |
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 .
docker buildx imagetools inspect "$IMAGE:$DOCKER_TAG"
# Show the package on the repository's Packages tab. Idempotent.
- run: |
apk add --no-cache -q curl
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|| echo "package already linked (or link refused); not fatal"
# The release, on the internal address. The job's own token may create
# releases; a tag it creates would not start a workflow, but this one
# already exists.
- env:
TAG: ${{ github.ref_name }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
body="INBUXA webmail $VERSION.\n\nImage: \`$IMAGE:$DOCKER_TAG\` (linux/amd64, linux/arm64), also tagged \`latest\`."
curl -fsS -o /dev/null -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
--data "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$body\"}" \
"$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/releases"
echo "release $TAG created"
- if: always()
run: docker logout "$REGISTRY" || true
+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. -->
+13 -2
View File
@@ -15,8 +15,19 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
# 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
+6 -5
View File
@@ -39,11 +39,12 @@ jobs:
permissions:
packages: write
steps:
# Pinned to a commit rather than a moving major tag. This action is
# handed `packages: write` and its whole job is deletion, so a tag
# repointed at something else -- by a compromise or a mistake upstream --
# is a bad day. v1.2.2.
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f
# 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
+15 -11
View File
@@ -63,7 +63,11 @@ 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
# inbuxa: this fork publishes to INBUXA's own path. Inherited from public
# ihasmail, which publishes ghcr.io/coffey-labs/ihasmail -- leaving that
# here would push INBUXA's webmail over the image every public ihasmail
# install pulls, which SPEC.md 5 exists to prevent.
IMAGE: ghcr.io/inbuxa/ihasmail-inbuxa
jobs:
# The version is worked out once and handed to both builds, so the two
@@ -76,11 +80,11 @@ jobs:
version: ${{ steps.v.outputs.version }}
docker_tag: ${{ steps.v.outputs.docker_tag }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: v
@@ -108,18 +112,18 @@ jobs:
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
- 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@v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
@@ -140,7 +144,7 @@ jobs:
# `image@sha256:sha256:...` when the reference is rebuilt.
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@v7
- 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 }}
@@ -157,13 +161,13 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+3 -3
View File
@@ -54,11 +54,11 @@ jobs:
previous: ${{ steps.decide.outputs.previous }}
count: ${{ steps.decide.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: decide
@@ -130,7 +130,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
+3
View File
@@ -1,6 +1,9 @@
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/
+91
View File
@@ -0,0 +1,91 @@
# 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,
# kept in the tree for reference and for the day the appeal succeeds.
#
# There is deliberately no publish job, although publish.yml is in the tree.
# 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, not an
# INBUXA one. A tag-driven publish here would ship plain ihasmail under the
# INBUXA name the moment upstream tags reached this project -- which happened
# once, by hand, and was deleted. Add one back only with a release scheme that
# produces tags this repository alone has.
#
# 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]
variables:
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: $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: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+29 -24
View File
@@ -1,6 +1,6 @@
# 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.
Thanks for your interest in contributing to the **INBUXA webmail**, an immutable, JMAP-only webmail client for the INBUXA mail server, built on ihasmail. Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
## Code of Conduct
@@ -9,21 +9,21 @@ By participating in this project, you agree to treat other contributors with res
## 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.
- ihasmail has **no database of its own** — all state lives on the mail server, over 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:
Before opening a new issue, please search [existing issues](https://git.coffeylabs.org/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.)
- Your environment: browser/OS, mail server 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
- Whether the issue is reproducible against a fresh mail server
### Suggesting Features
@@ -41,7 +41,7 @@ For larger changes, please open an issue to discuss the approach **before** subm
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.
5. **Test your changes** against a real (or local) mail server 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
@@ -58,6 +58,12 @@ 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.
@@ -111,7 +117,7 @@ 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
run it: `npm run dev:mock` (the mock mail server, 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__/`.
@@ -122,7 +128,7 @@ examples in `web/src/views/*/__tests__/`.
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.
2. Point your local instance at a running INBUXA 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.
@@ -131,8 +137,8 @@ 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 # a real mail server (MAIL_SERVER_URL in .env) — server :8080, Vite :5173
npm run dev:mock # built-in mock mail server ([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
@@ -147,38 +153,38 @@ build.
#### Architecture
```
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► mail server
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.
- `server/` — Node/Hono backend: authenticates against the mail server'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 mail server 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.
`quota`, `blob`, `filenode`, EventSource push, plus the mail server's own
registry capability. Features degrade gracefully when one is missing.
#### The mock
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
An in-memory fake mail server — 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
because each cost a live debugging session: the registry capability advertised
**per-account** rather than session-level, identity signatures capped at 2047
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
**bytes**, and `CalendarEvent/set` speaking the server'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_REGISTRY=1` | Omits the registry 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 |
| `MOCK_METRICS=off` | Refuses the dashboard's metric history, as a server without metrics history does |
| `MOCK_EDITION=enterprise` | Reports the `enterprise` edition, for code that still reads it |
It tracks the current Stalwart release rather than 0.16 in general, and each
It tracks the current mail server release, 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
@@ -193,9 +199,8 @@ at build time — nothing writes a version into the tree, and `package.json` sta
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,
build came from, not how new it is. The version says nothing about the mail server on
purpose: the server's own version is its own business. Building an image with the version on it,
and the single-host `deploy.example.sh`, are covered in
[Installing](https://docs.ihasmail.org/install/).
-1844
View File
File diff suppressed because it is too large Load Diff
-152
View File
@@ -1,152 +0,0 @@
# 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).
- **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.
+64 -74
View File
@@ -1,114 +1,104 @@
<p align="center">
<img src="web/public/img/logo.png" alt="ihasmail" width="150">
<img src="web/public/img/inbuxa-mark.png" alt="" width="110">
</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>
<h1 align="center">INBUXA webmail</h1>
<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
**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.
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.
| | |
| --- | --- |
| 🌐 **[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 |
## Screenshots
| | |
| --- | --- |
| **Inbox & conversation (dark)** ![Inbox, dark theme](docs/screenshots/inbox-dark.jpg) | **Inbox & conversation (light)** ![Inbox, light theme](docs/screenshots/inbox-light.jpg) |
| **Composer** ![Composer](docs/screenshots/compose.jpg) | **Calendar** ![Calendar](docs/screenshots/calendar.jpg) |
| **Contacts** ![Contacts](docs/screenshots/contacts.jpg) | **Sieve filter builder** ![Filters](docs/screenshots/filters.jpg) |
Taken against the built-in mock with sample data. More, including the phone
layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
The webmail of the INBUXA suite: mail, calendars, contacts, files and filters
in one app that works as well on a phone as on a desktop. It talks only JMAP to
the INBUXA mail server, and keeps nothing of its own: everything durable,
settings included, lives on the server, so the container is disposable.
## 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
- **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**, stored on the mail server.
- **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 to an account whose role allows it.
Everything else is in INBUXA Admin.
- **Sign-in on the mail server's own page**, two-factor included. The webmail
never handles a password to sign someone in, and holds only sealed tokens.
- **Ten interface languages and twelve themes.**
The long version is [FEATURES.md](FEATURES.md) and
[ihasmail.org](https://ihasmail.org/#features).
## Configuration
## Requirements
| Variable | Meaning |
|---|---|
| `MAIL_SERVER_URL` | How this webmail reaches the mail server. |
| `APP_SECRET` | A long random secret for sealing sessions. Required in production. |
| `OAUTH_CLIENT_SECRET` | Turns on sign-in through the server's page. The secret of the confidential client the server registers for this webmail: on the server, the same value as `INBUXA_WEBMAIL_CLIENT_SECRET`. |
| `OAUTH_CLIENT_ID` | The client's id. Default `ihasmail-inbuxa`, which is what the server registers. |
| `PUBLIC_URL` | Where browsers reach the webmail, without `BASE_PATH`. Required with `OAUTH_CLIENT_SECRET`. The redirect URI, `PUBLIC_URL` + `BASE_PATH` + `/api/auth/callback`, must match the server's `INBUXA_WEBMAIL_URL` + `/api/auth/callback` exactly. |
| `MAIL_SERVERS_FILE` | Optional: several mail servers, picked by the account's domain. See `mail-servers.example.json`. |
| `ADMIN_URL` | Optional: where INBUXA Admin is, for the dashboard's link. |
| `APP_NAME` | What the webmail calls itself. Default `INBUXA`, shown as the INBUXA wordmark; any other name shows as text. |
**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).
`.env.example` lists the rest.
- **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.
On the mail server, set `INBUXA_WEBMAIL_URL` to the webmail's address (with
`BASE_PATH`, if any) and `INBUXA_WEBMAIL_CLIENT_SECRET` to the shared secret.
The server registers the client on start and allows the webmail's origin for
cross-origin requests.
With one mail server, the sign-in page asks for no address, only whether this
is the person's own device. The server's page asks for the rest. With several,
the address comes first, since its domain picks the server.
A password change revokes the server's tokens, so it signs the person out
everywhere, this session included.
## Quick start (Docker)
```bash
cp .env.example .env
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
# edit: MAIL_SERVER_URL, APP_SECRET, and for server sign-in OAUTH_CLIENT_SECRET and PUBLIC_URL
docker compose up --build -d
# → http://localhost:8080 — put a reverse proxy in front for TLS
# → http://localhost:8080. Put a reverse proxy in front for TLS.
```
Or pull the published image, `ghcr.io/coffey-labs/ihasmail`. Releases are
weekly, so it is usually a few days behind `main`.
## Source code
People sign in with their Stalwart mailbox credentials. **An account with
two-factor authentication needs an app password**, created in Stalwart's own
settings.
INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork:
<https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa>. The sign-in page and Settings
About link there, beside the version, which names the commit the running build
came from.
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/).
Run your own patched build and that offer becomes yours, not ours: point
`SOURCE_URL` at your tree and both links follow it.
## Development
```bash
npm install
npm run dev:mock # built-in mock Stalwart ([email protected] / demo)
npm run dev:mock # the built-in mock mail server ([email protected] / demo)
npm test
```
The mock also answers OAuth. Start it and the webmail with
`OAUTH_CLIENT_SECRET=mock-oauth-secret` and a `PUBLIC_URL`, and its sign-in
page approves the demo user at once.
Architecture, the mock's switches and how versions are numbered are in
[CONTRIBUTING.md](CONTRIBUTING.md#development-setup).
## Contributing
## Built on ihasmail
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
The INBUXA webmail is built on [ihasmail](https://git.coffeylabs.org/coffey-labs/ihasmail),
Coffey Labs' own webmail, which stays an independent product. The public
repository is the remote `ihasmail`, fetch-only, and its `main` is merged in to
keep up. Nothing here is pushed there.
## License
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/).
Copyright (C) 2026 Coffey Labs. AGPL-3.0-or-later; see [LICENSE](LICENSE).
-33
View File
@@ -1,33 +0,0 @@
# 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.
+5 -5
View File
@@ -22,13 +22,13 @@ 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
- The version of the mail server you were testing against, if relevant
- Whether the issue is in the webmail itself, in how it talks to the mail server 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.
- **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on the mail server's store over JMAP, some reports may need to be routed to or coordinated with the mail server's own 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
@@ -42,9 +42,9 @@ In scope:
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 the INBUXA mail server itself: report those to the mail server's own project
- 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
- Issues requiring physical access to a user's device or an already-compromised mail server
## Disclosure Policy
+16 -3
View File
@@ -9,14 +9,27 @@ services:
BASE_PATH: ${BASE_PATH:-}
image: ihasmail:2
restart: unless-stopped
# 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:
- "8080:8080"
- "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}
MAIL_SERVER_URL: ${MAIL_SERVER_URL:?set MAIL_SERVER_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}
SOURCE_URL: ${SOURCE_URL:-https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa}
TRUST_PROXY: "1"
IMAGE_PROXY: "1"
volumes:
@@ -1,15 +1,15 @@
{
"_comment": [
"Optional: which Stalwart a domain signs in to.",
"Optional: which mail server a domain signs in to.",
"",
"STALWART_URL stays required and stays the default. This file only adds",
"MAIL_SERVER_URL stays required and stays the default. This file only adds",
"domains that go somewhere else -- delete it and nothing changes.",
"",
"Point at it with STALWART_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
"Point at it with MAIL_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
"it read-only. Read once at startup, so editing it means restarting.",
"",
"A domain that is not listed here, and a bare username with no domain at",
"all, go to STALWART_URL. A domain that IS listed never falls back: if its",
"all, go to MAIL_SERVER_URL. A domain that IS listed never falls back: if its",
"server is unreachable that sign-in fails, because falling back would",
"authenticate somebody against a server their domain was routed away from.",
"",
@@ -20,10 +20,10 @@
"ihasmail's Administration dashboard links to each server's own",
"administration, found from the server. A value may instead be an object",
"that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.",
"STALWART_ADMIN_URL is the same for the default server. A listed domain is",
"ADMIN_URL is the same for the default server. A listed domain is",
"never pointed at the default server's administration.",
"",
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
"Each listed domain signs in to its own server; everything else goes to MAIL_SERVER_URL."
],
"example.com": "https://mail.example.com",
+3 -3
View File
@@ -20,11 +20,11 @@
"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\"",
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"MAIL_SERVER_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\" \"MAIL_SERVER_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\""
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"MAIL_SERVER_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
},
"devDependencies": {
"concurrently": "^10.0.5",

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+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`);
@@ -5,8 +5,8 @@
* images already use rather than whatever a window happens to be.
*
* npm run dev:mock # in another terminal
* node docs/screenshots.mjs docs/screenshots
* node docs/screenshots-light.mjs docs/screenshots
* 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.
@@ -30,7 +30,7 @@
* 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 docs/screenshots-light.mjs does.
* 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
@@ -226,7 +226,7 @@ try {
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 docs/screenshots-light.mjs -- see the header)
// (inbox-light is captured by scripts/screenshots-light.mjs -- see the header)
// --- calendar ---
+81 -3
View File
@@ -12,7 +12,7 @@ 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.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-account-flows";
const mock = await import("./mock/index.js");
@@ -69,7 +69,7 @@ test("the registry reports an account with nothing set up yet", async () => {
});
test("app passwords are created, listed once with their secret, and revoked", async () => {
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
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);
@@ -84,8 +84,86 @@ test("app passwords are created, listed once with their secret, and revoked", as
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: " " });
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
assert.equal(res.status, 400);
assert.equal(res.body.error, "missing_fields");
});
+1 -1
View File
@@ -72,7 +72,7 @@ async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodRespon
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);
if (!res.ok) throw new UpstreamError(`The mail server rejected the request (${res.status})`, 502);
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
}
+12 -2
View File
@@ -14,8 +14,18 @@ test("mail, calendars and the rest pass untouched", () => {
assert.equal(r.ok, true);
});
test("the account's own registry objects pass", () => {
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).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", () => {
+10 -3
View File
@@ -18,7 +18,7 @@
* 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", "ApiKey", "PublicKey", "MaskedEmail"]);
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "PublicKey", "MaskedEmail"]);
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
@@ -83,8 +83,15 @@ export function gateAdministration(raw: string): GateResult {
const name = Array.isArray(call) ? call[0] : undefined;
if (typeof name !== "string") return { ok: false, method: null };
if (!name.startsWith("x:")) continue;
const object = name.slice(2).split("/")[0] ?? "";
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
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) };
}
+6 -6
View File
@@ -14,16 +14,16 @@ writeFileSync(
"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;
process.env.MAIL_SERVER_URL = "https://default.example";
process.env.ADMIN_URL = "https://admin.default.example/";
process.env.MAIL_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
* Where the dashboard's "Open Stalwart admin" points. MAIL_SERVER_URL is how this
* server reaches 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", () => {
@@ -67,7 +67,7 @@ test("the origin is the one Stalwart advertises, even when it is reached on a pr
});
test("the shipped example loads through the parser that reads it", () => {
const example = new URL("../../stalwart-servers.example.json", import.meta.url);
const example = new URL("../../mail-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));
+2 -2
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
test("CSRF guard rejects API POSTs without the custom header", async () => {
@@ -111,7 +111,7 @@ test("only a PDF blob may be framed, and only by us", async () => {
/*
* #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,
* MAIL_SERVER_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.
+375 -32
View File
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
import { bodyLimit } from "hono/body-limit";
import { compress } from "hono/compress";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
@@ -10,9 +11,10 @@ import { getConnInfo } from "@hono/node-server/conninfo";
import { config } from "./config.js";
import { fetchPermissions } from "./permissionSchema.js";
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
import { SessionStore, accountKey, type SessionBackend, type LiveSession } from "./sessions.js";
import { RateLimiter } from "./ratelimit.js";
import { resolveClientIp } from "./clientip.js";
import { rateLimitKey, resolveClientIp } from "./clientip.js";
import { safeEqual } from "./crypto.js";
import {
type AccountInfo,
UpstreamError,
@@ -39,6 +41,7 @@ import {
revokeAppPassword,
} from "./account.js";
import { imageProxyHandler } from "./imageproxy.js";
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
import { icsProxyHandler } from "./icsproxy.js";
import { staticHandler } from "./static.js";
@@ -197,6 +200,9 @@ function compressResponses(basePath: string): MiddlewareHandler {
}
const csrfGuard: MiddlewareHandler = async (c, next) => {
// The mail server's sign-in page sends the browser back here, so this one
// arrives cross-site by design. Its state, bound to a cookie, stands in.
if (c.req.method === "GET" && c.req.path.endsWith("/api/auth/callback")) return next();
const site = c.req.header("sec-fetch-site");
if (site && site !== "same-origin" && site !== "none") {
return c.json({ error: "cross_site_request" }, 403);
@@ -209,9 +215,38 @@ const csrfGuard: MiddlewareHandler = async (c, next) => {
await next();
};
/**
* The largest body an API route that reads JSON will take.
*
* Hono reads a JSON body whole, and before this nothing bounded it: a few
* unauthenticated sign-in attempts carrying hundreds of megabytes each could
* run the process out of memory, and a restart signs everybody out. What
* these routes actually receive is a username and password, or a code.
*
* JMAP and uploads carry real payloads and bound themselves as they stream;
* the push callback has its own limit ahead of this one.
*/
const MAX_SMALL_BODY = 64 * 1024;
const LARGE_BODY_ROUTE = /\/api\/(jmap$|upload\/)/;
const limitSmallBody = bodyLimit({ maxSize: MAX_SMALL_BODY, onError: (c) => c.json({ error: "too_large" }, 413) });
const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req.path) ? next() : limitSmallBody(c, next));
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
const cookie = getCookie(c, config.cookieName);
const session = sessions.resolve(cookie);
let session = sessions.resolve(cookie);
if (session?.tokens && needsRefresh(session.tokens)) {
try {
session = await refreshSession(cookie!, session);
} catch (err) {
// Couldn't ask the server. The token may still have a few minutes; if
// not, the call itself will say so.
console.warn("[ihasmail] token refresh failed:", (err as Error).message);
}
if (!session) {
deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ error: "unauthenticated" }, 401);
}
}
if (!session) {
return c.json({ error: "unauthenticated" }, 401);
}
@@ -219,6 +254,55 @@ const requireSession: MiddlewareHandler<Env> = async (c, next) => {
await next();
};
/*
* One refresh per session at a time: a page opening does several requests at
* once, and each would otherwise renew the same token.
*/
const refreshing = new Map<string, Promise<LiveSession | null>>();
/**
* Renew an OAuth session's access token and keep the new one. Null when the
* server refused the refresh token (a password change revokes it), which
* ends the session.
*/
function refreshSession(cookie: string, session: LiveSession): Promise<LiveSession | null> {
let inFlight = refreshing.get(session.id);
if (!inFlight) {
inFlight = (async () => {
const renewed = await refreshTokens(upstreamFor(session.username), session.tokens!);
if (!renewed) {
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
return null;
}
sessions.updateTokens(cookie, renewed);
return sessions.resolve(cookie);
})().finally(() => refreshing.delete(session.id));
refreshing.set(session.id, inFlight);
}
return inFlight;
}
/**
* What push keeps to renew an account's subscription long after the session
* that started it. A password is good until it changes; OAuth tokens get a
* copy that renews itself, since push outlives any one access token.
*/
export function pushCredential(session: LiveSession): { get(): Promise<string> } {
if (!session.tokens) {
const authorization = session.authorization;
return { get: async () => authorization };
}
let tokens: TokenSet = session.tokens;
const base = upstreamFor(session.username);
return {
async get() {
if (needsRefresh(tokens)) tokens = (await refreshTokens(base, tokens)) ?? tokens;
return `Bearer ${tokens.access}`;
},
};
}
/**
* Scope the session cookie to the mount, not the whole host.
*
@@ -269,6 +353,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
const api = new Hono<Env>();
api.use("*", csrfGuard);
api.use("*", smallBodies);
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
@@ -297,12 +382,96 @@ export function createApp(basePath = config.basePath): Hono<Env> {
/* Sent before sign-in like the rest of this: it says what the
installation has decided, not anything about who is asking. */
settingsPolicy: config.settingsPolicy,
/* "oauth": sign in on the mail server's own page (see oauth.ts). */
signIn: oauthEnabled() ? "oauth" : "password",
/* With "oauth": true when the server's page can take it from here, so
the sign-in form doesn't ask for an address first. */
signInDirect: oauthEnabled() && singleServer(),
}),
);
// ---------- Auth ----------
/*
* Sign-in through the mail server's page. `start` sends the browser there;
* `callback` is where the server sends it back. See oauth.ts.
*/
const OAUTH_STATE_COOKIE = `${config.cookieName}_signin`;
api.get("/auth/oauth/start", async (c) => {
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
const rateIp = rateLimitKey(clientIp(c));
if (!loginFloodLimiter.check(rateIp)) {
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
return c.redirect(`${basePath}/?signin_error=rate_limited`, 302);
}
const username = (c.req.query("username") ?? "").trim().slice(0, 320);
try {
const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" });
setCookie(c, OAUTH_STATE_COOKIE, state, { httpOnly: true, sameSite: "Lax", secure: isSecureRequest(c), path: `${basePath}/api/auth`, maxAge: 600 });
return c.redirect(location, 302);
} catch (err) {
console.warn("[ihasmail] could not start sign-in:", (err as Error).message);
return c.redirect(`${basePath}/?signin_error=unavailable`, 302);
}
});
api.get("/auth/callback", async (c) => {
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
const boundState = getCookie(c, OAUTH_STATE_COOKIE);
deleteCookie(c, OAUTH_STATE_COOKIE, { path: `${basePath}/api/auth` });
const fail = (code: string) => c.redirect(`${basePath}/?signin_error=${code}`, 302);
const rateIp = rateLimitKey(clientIp(c));
if (!loginFloodLimiter.check(rateIp)) return fail("rate_limited");
const state = c.req.query("state") ?? "";
const code = c.req.query("code") ?? "";
// The server's page sends `error` when the person cancels or is refused.
if (!code || c.req.query("error")) return fail("cancelled");
let result;
try {
result = await finishSignIn({ state, boundState, code });
} catch (err) {
if (err instanceof SignInError) return fail(err.code);
console.warn("[ihasmail] sign-in exchange failed:", (err as Error).message);
return fail("unavailable");
}
const authorization = `Bearer ${result.tokens.access}`;
try {
const upstream = await fetchUpstreamSession(authorization, result.base);
if (!hasStalwartRegistry(upstream)) return fail("unsupported_server");
const username = upstream.username || result.username;
// Every later call finds the account's server from its name. If the
// server signed in an account that routes elsewhere, calls would go to
// the wrong server, so refuse it.
if (upstreamFor(username) !== result.base) return fail("wrong_account");
const { cookie, session } = sessions.create({
username,
account: accountKey(result.base, username),
tokens: result.tokens,
remember: result.remember,
userAgent: c.req.header("user-agent") ?? "",
ip: clientIp(c),
});
setSessionCookie(c, cookie, session.remember);
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
return c.redirect(`${basePath}/`, 302);
} catch (err) {
console.warn("[ihasmail] sign-in failed after the exchange:", (err as Error).message);
return fail("unavailable");
}
});
api.post("/auth/login", async (c) => {
// With sign-in on the mail server's page, this form never sees a password.
if (oauthEnabled()) return c.json({ error: "oauth_required", message: "Sign in on the mail server's page." }, 403);
const ip = clientIp(c);
// What the limits count under: the address, or its /64 for IPv6.
const rateIp = rateLimitKey(ip);
// The flood ceiling needs nothing from the body, so it goes before reading one.
if (!loginFloodLimiter.check(rateIp)) {
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
}
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
try {
body = await c.req.json();
@@ -318,7 +487,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
/*
* Three checks, answering different questions.
*
* `limitKey` is this username from this address, and `ip` is any username
* `limitKey` is this username from this address, and `rateIp` is any username
* from it -- both guard guessing, and both are given back when the upstream
* never got as far as judging the password. Refunding only the first would
* not fix #239: ten retries through an outage would still spend the address
@@ -328,12 +497,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
* The flood ceiling is the one that is never refunded, and it is the reason
* the other two safely can be.
*/
const limitKey = `${ip}|${username.toLowerCase()}`;
if (!loginFloodLimiter.check(ip)) {
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(ip)));
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
}
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
const limitKey = `${rateIp}|${username.toLowerCase()}`;
if (!loginLimiter.check(limitKey) || !loginLimiter.check(rateIp)) {
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
}
@@ -351,12 +516,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
// The credentials were accepted; only the server is too old. Not an
// attempt worth counting against them.
loginLimiter.refund(limitKey);
loginLimiter.refund(ip);
loginLimiter.refund(rateIp);
return c.json(
{
error: "unsupported_server",
message:
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
"Your credentials are fine, but this mail server isn't one this webmail supports.",
},
501,
);
@@ -364,6 +529,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
loginLimiter.reset(limitKey);
const { cookie, session } = sessions.create({
username,
account: accountKey(upstreamFor(username), upstream.username || username),
password: effectivePassword,
remember: Boolean(body.remember),
userAgent: c.req.header("user-agent") ?? "",
@@ -373,7 +539,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
// Start the account's push subscription now, so it is usually verified
// by the time the browser opens its stream. See push.ts.
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
if (mailAccount) pushPrepare(session.username, mailAccount, session.authorization);
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) {
@@ -398,7 +564,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
{
error: "totp_unsupported",
message:
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.",
"This mail server does not accept two-factor codes from this form. Sign in with an app password instead. Your password and code are probably fine.",
},
401,
);
@@ -411,7 +577,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
*/
if (!(err instanceof UpstreamError && err.status === 401)) {
loginLimiter.refund(limitKey);
loginLimiter.refund(ip);
loginLimiter.refund(rateIp);
}
return upstreamFailure(c, err);
}
@@ -445,12 +611,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
api.get("/auth/sessions", requireSession, (c) => {
const session = c.get("session");
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) });
return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
});
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
const session = c.get("session");
const n = sessions.destroyAllForUser(session.username, session.id);
const n = sessions.destroyAllForUser(session.account, session.id);
return c.json({ revoked: n });
});
@@ -463,7 +629,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
const accountCtx = async (c: Context<Env>) => {
const session = c.get("session");
// The account's own server. Without it, the first fetch after the cached
// session expires goes to STALWART_URL -- which, for a domain mapped
// session expires goes to MAIL_SERVER_URL -- which, for a domain mapped
// elsewhere, either refuses the password or knows a different account by
// the same name (#238).
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
@@ -478,8 +644,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
};
/** Guard the endpoints that check a password against brute-forcing. */
const guarded = (c: Context<Env>): Response | null => {
const key = `account|${c.get("session").username.toLowerCase()}`;
const guarded = (c: Context<Env>, scope = "account"): Response | null => {
const key = `${scope}|${c.get("session").username.toLowerCase()}`;
if (accountLimiter.check(key)) return null;
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
@@ -512,12 +678,20 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} catch (err) {
return accountFailure(c, err);
}
if (session.tokens) {
// The server revokes every token when the password changes, this
// session's included, so there is nothing to keep: sign in again.
forgetUpstreamSession(session.id);
const revoked = sessions.destroyAllForUser(session.account);
deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ ok: true, revokedSessions: revoked - 1, signedOut: true });
}
// The old password is now dead: re-seal this session with the new one and
// drop the others, whose sealed copies would fail on their next call.
const otpCode = body.otpCode?.trim();
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
forgetUpstreamSession(session.id);
const revoked = sessions.destroyAllForUser(session.username, session.id);
const revoked = sessions.destroyAllForUser(session.account, session.id);
return c.json({ ok: true, revokedSessions: revoked });
});
@@ -531,12 +705,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
}
});
/*
* An app password is a credential that outlives this session, a password
* change and a sign-out -- so minting one asks for the account password, as
* changing the password does. Otherwise a session left open on somebody
* else's machine is enough to take a permanent key away from it.
*/
api.post("/account/app-passwords", requireSession, async (c) => {
// A budget of its own: guessing here never reaches Stalwart (see confirmsPassword).
const limited = guarded(c, "app-password");
if (limited) return limited;
const session = c.get("session");
const body = await readJson<{ description?: string }>(c);
const body = await readJson<{ description?: string; current?: string }>(c);
if (!body) return c.json({ error: "bad_request" }, 400);
const description = (body.description ?? "").trim().slice(0, 120);
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
const current = body.current ?? "";
if (!current || current.length > 1024) return c.json({ error: "missing_fields", message: "Enter your current password." }, 400);
if (!(await confirmsPassword(session, current))) {
return c.json({ error: "invalid_credentials", message: "That password is not correct." }, 403);
}
try {
return c.json(await createAppPassword(await accountCtx(c), { description }));
} catch (err) {
@@ -588,6 +776,16 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} catch (err) {
return accountFailure(c, err);
}
if (session.tokens) {
// Signed in on the server's page, where two-factor is asked for, so
// nothing here needs moving onto an app password.
try {
await enableOtp(ctx, { url: body.url, code, current: body.current });
} catch (err) {
return accountFailure(c, err);
}
return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
}
let app: { id: string; secret: string } | null = null;
try {
app = await createAppPassword(ctx, { description: appPasswordName(c) });
@@ -611,7 +809,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
if (sessionKept) forgetUpstreamSession(session.id);
}
// Other sessions still hold the bare password and will be refused.
const revoked = sessions.destroyAllForUser(session.username, session.id);
const revoked = sessions.destroyAllForUser(session.account, session.id);
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
});
@@ -626,6 +824,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} catch (err) {
return accountFailure(c, err);
}
if (session.tokens) return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
// This session may be running on the app password minted when 2FA went on;
// the plain password works again now, so put it back.
sessions.reseal(getCookie(c, config.cookieName), body.current);
@@ -648,12 +847,27 @@ export function createApp(basePath = config.basePath): Hono<Env> {
*/
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
if (!administrationAllowed(config.administration, session.remember)) {
const held = gatedReads.get(session.id) ?? 0;
if (held >= MAX_GATED_PER_SESSION) {
c.header("Retry-After", "1");
return c.json({ error: "rate_limited" }, 429);
}
gatedReads.set(session.id, held + 1);
let raw: string;
try {
if (Number(c.req.header("content-length") ?? "0") > MAX_GATED_REQUEST) return c.json({ error: "too_large" }, 413);
// Counted as it arrives: a chunked body carries no length to refuse up front.
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : "";
} catch {
raw = c.req.raw.body ? await readGated(c.req.raw.body) : "";
} catch (err) {
if (err instanceof GatedBudgetError) {
c.header("Retry-After", "1");
return c.json({ error: "busy" }, 503);
}
return c.json({ error: "too_large" }, 413);
} finally {
const left = (gatedReads.get(session.id) ?? 1) - 1;
if (left > 0) gatedReads.set(session.id, left);
else gatedReads.delete(session.id);
}
const gate = gateAdministration(raw);
if (!gate.ok) {
@@ -751,23 +965,37 @@ export function createApp(basePath = config.basePath): Hono<Env> {
try {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
// A PDF viewer or a video element asks for pieces; pass that on. A server
// that ignores it answers with the whole file, as it did before.
const range = c.req.header("range");
const res = await fetch(url, {
// Ask for the bytes as they are. undici would otherwise negotiate gzip
// on our behalf and hand back a decompressed body whose content-length
// header still describes the compressed one -- see forwardedContentLength.
headers: { authorization: session.authorization, "accept-encoding": "identity" },
headers: { authorization: session.authorization, "accept-encoding": "identity", ...(range && /^bytes=[\d,\s-]+$/.test(range) ? { range } : {}) },
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
});
if (res.status === 416) return c.body(null, 416);
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
const headers = new Headers();
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
headers.set("Content-Type", type);
const cl = forwardedContentLength(res.headers);
if (cl) headers.set("Content-Length", cl);
const partial = res.status === 206 && res.headers.get("content-range");
if (partial) headers.set("Content-Range", partial);
/*
* Said here because Stalwart does not say it. It honors a single byte
* range but sends no `Accept-Ranges` (0.16.22, checked live on
* 2026-09-16), and Chrome's PDF viewer only reads a file in pieces when
* the first response advertises it. A server that ignores a range sends
* the whole file, which the browser takes just as well.
*/
headers.set("Accept-Ranges", "bytes");
const safeInline = inline && isInlineSafe(type);
headers.set(
"Content-Disposition",
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(withoutBidiControls(name))}`,
);
headers.set("X-Content-Type-Options", "nosniff");
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
@@ -786,8 +1014,15 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} else {
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
}
headers.set("Cache-Control", "private, max-age=3600");
return new Response(res.body, { status: 200, headers });
// Kept out of the browser's disk cache on a device that is not the
// person's own: signing out wipes what the app stores, not that.
/*
* A blob id names its content -- the same id is the same bytes for good
* -- so on the reader's own device there is nothing to revalidate. On
* anyone else's, nothing is left in the disk cache at all.
*/
headers.set("Cache-Control", session.remember ? "private, max-age=31536000, immutable" : "no-store");
return new Response(res.body, { status: partial ? 206 : 200, headers });
} catch (err) {
return upstreamFailure(c, err);
}
@@ -807,7 +1042,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
// relay, and is moved to fan-out the moment the account verifies.
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
if (accountId && pushAttach(session.username, accountId, session.authorization, out)) {
if (accountId && pushAttach(session.username, accountId, pushCredential(session), out)) {
out.writeHead(200, SSE_HEADERS);
out.flushHeaders();
out.write(": subscribed\n\n");
@@ -872,6 +1107,71 @@ async function readJson<T>(c: Context): Promise<T | null> {
}
}
/**
* Is `candidate` the password of the account this session is signed in to?
*
* Compared with the credential the session holds first, which costs nothing
* and tells Stalwart nothing -- its auto-ban counts failures against the
* proxy's address, which every user shares. That credential is the password,
* with a TOTP code after a `$` when one was given at sign-in. A session that
* turning on 2FA moved onto an app password (Stalwart's secrets start
* `$app$`) holds something else, and only then is the candidate put to the
* server.
*/
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
if (session.tokens) {
// Holding no password, the only judge is the server.
try {
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
await fetchUpstreamSession(authorization, upstreamFor(session.username));
return true;
} catch {
return false;
}
}
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
const held = decoded.slice(decoded.indexOf(":") + 1);
if (safeEqual(held, candidate)) return true;
const withoutCode = held.replace(/\$\d{6,8}$/, "");
if (withoutCode !== held && safeEqual(withoutCode, candidate)) return true;
// Holding the password, the comparison above is the answer, and a wrong
// guess never reaches the server's auto-ban.
if (!held.startsWith("$app$")) return false;
try {
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
await fetchUpstreamSession(authorization, upstreamFor(session.username));
return true;
} catch {
return false;
}
}
/**
* After a two-factor change on a token session: whether the server still
* honors this session's token. If it revoked it, end the session here too,
* so the web app can send the person to sign in again.
*/
async function afterCredentialChange(c: Context<Env>, session: LiveSession): Promise<{ signedOut: boolean }> {
forgetUpstreamSession(session.id);
try {
await fetchUpstreamSession(session.authorization, upstreamFor(session.username));
return { signedOut: false };
} catch (err) {
if (!(err instanceof UpstreamError && err.status === 401)) return { signedOut: false };
sessions.destroyAllForUser(session.account);
deleteCookie(c, config.cookieName, { path: cookiePath });
return { signedOut: true };
}
}
/**
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
* read as a PDF in the downloads list. A filename has no use for them.
*/
function withoutBidiControls(name: string): string {
return name.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
}
/** Name the app password after the browser it will live in. */
function appPasswordName(c: Context): string {
const ua = c.req.header("user-agent") ?? "";
@@ -889,6 +1189,8 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
sessionId: session.id,
loginName: session.username,
remember: session.remember,
/** "oauth": signed in on the mail server's page, holding tokens, not a password. */
signIn: session.tokens ? "oauth" : "password",
/** Locale configured for the account in Stalwart's directory, if readable. */
userLocale: info.locale,
/**
@@ -931,9 +1233,50 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
*/
/**
* The largest JMAP request read into memory for the administration check.
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way.
*
* Only sessions that may not administer come this way, and what the client
* sends is small: attachments and pasted images go through `/upload`, and the
* composer turns inline images into uploads before a draft is saved. Stalwart
* would take up to its `maxSizeRequest` (10 MB by default), but a request is
* held here as a string, parsed and serialized again, so each one costs
* several times its size; 4 MB is far past anything the client sends.
*/
const MAX_GATED_REQUEST = 16 * 1024 * 1024;
const MAX_GATED_REQUEST = 4 * 1024 * 1024;
/**
* How many checked requests one session may have in flight at once. Matches
* the `maxConcurrentRequests` Stalwart advertises by default, which the client
* already stays within.
*/
const MAX_GATED_PER_SESSION = 4;
/**
* The bytes all checked requests together may hold at once. Counted as they
* arrive rather than reserved up front, so a slow body that has sent little
* holds little, and a burst of large ones is turned away with a 503 instead of
* taking the process down.
*/
const GATED_BUDGET = 32 * 1024 * 1024;
const gatedReads = new Map<string, number>();
let gatedBytes = 0;
class GatedBudgetError extends Error {}
async function readGated(stream: ReadableStream<Uint8Array>): Promise<string> {
let mine = 0;
const counted = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
mine += chunk.byteLength;
gatedBytes += chunk.byteLength;
if (mine > MAX_GATED_REQUEST) controller.error(new Error("request too large"));
else if (gatedBytes > GATED_BUDGET) controller.error(new GatedBudgetError("gated read budget spent"));
else controller.enqueue(chunk);
},
});
try {
return await new Response(stream.pipeThrough(counted)).text();
} finally {
gatedBytes -= mine;
}
}
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
+1 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
/**
+18
View File
@@ -97,3 +97,21 @@ export function resolveClientIp(peer: string, headers: ForwardHeaders, cfg: Trus
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`;
}
+1 -1
View File
@@ -19,7 +19,7 @@ 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";
process.env.MAIL_SERVER_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 () => {
+37 -18
View File
@@ -57,7 +57,7 @@ if (!appSecret || appSecret === "change-me") {
);
}
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
const stalwartUrl = env("MAIL_SERVER_URL", "https://mail.example.com").replace(/\/+$/, "");
/**
* Declares that this instance is running as an immutable container: read-only
@@ -190,7 +190,7 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
/**
* Which Stalwart a domain signs in to.
*
* `STALWART_URL` stays required and stays the default; this only adds domains
* `MAIL_SERVER_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.
*
@@ -203,15 +203,15 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
* 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;
const file = process.env.MAIL_SERVERS_FILE;
if (!file) return { urls: {}, adminUrls: {} };
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
if (!existsSync(file)) throw new Error(`MAIL_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}`);
throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): ${(err as Error).message}`);
}
return parseStalwartServers(raw, file);
}
@@ -219,7 +219,7 @@ function readStalwartServers(): { urls: Record<string, string>; adminUrls: Recor
/** 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`);
throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): expected an object of domain to URL`);
}
const out: Record<string, string> = {};
@@ -233,16 +233,16 @@ export function parseStalwartServers(raw: unknown, file: string): { urls: Record
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`);
if (!domain) throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): a domain key is empty`);
if (domain in out) throw new Error(`Invalid MAIL_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 (typeof value.url !== "string") throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): "${domain}" is not a URL`);
out[domain] = httpUrl(value.url, `MAIL_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`);
if (typeof value.adminUrl !== "string") throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`);
adminUrls[domain] = httpUrl(value.adminUrl, `MAIL_SERVERS_FILE (${file}): "${domain}" adminUrl`);
}
}
return { urls: out, adminUrls };
@@ -262,9 +262,23 @@ function httpUrl(raw: string, where: string): string {
const stalwartServers = readStalwartServers();
/*
* Signing in through the mail server's own page. On when OAUTH_CLIENT_SECRET
* is set: the secret of the confidential client the server registers for this
* webmail (INBUXA registers `ihasmail-inbuxa` from INBUXA_WEBMAIL_URL and
* INBUXA_WEBMAIL_CLIENT_SECRET). PUBLIC_URL is where browsers reach ihasmail,
* without BASE_PATH; the redirect URI is built from it and must match the one
* registered exactly.
*/
const oauthClientSecret = process.env.OAUTH_CLIENT_SECRET ?? "";
const publicUrl = process.env.PUBLIC_URL ? httpUrl(process.env.PUBLIC_URL, "PUBLIC_URL") : "";
if (oauthClientSecret && !publicUrl) {
throw new Error("OAUTH_CLIENT_SECRET is set but PUBLIC_URL is not: the sign-in redirect needs ihasmail's public address");
}
export const config = {
isProd,
appName: env("APP_NAME", "ihasmail"),
appName: env("APP_NAME", "INBUXA"),
settingsPolicy: readSettingsPolicy(),
/**
* What this build calls itself: `2.16.57`. Set by the image build from
@@ -278,9 +292,10 @@ export const config = {
*
* 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.
* ihasmail should point this at their own tree. ihasmail-inbuxa is itself
* such a tree, so the default is INBUXA's fork.
*/
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
sourceUrl: env("SOURCE_URL", "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa"),
host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080),
/**
@@ -300,12 +315,12 @@ export const config = {
stalwartServers: stalwartServers.urls,
/**
* Where an administrator reaches Stalwart's own administration, for the
* pointer on ihasmail's dashboard. Optional, and separate from STALWART_URL,
* pointer on ihasmail's dashboard. Optional, and separate from MAIL_SERVER_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") : "",
stalwartAdminUrl: process.env.ADMIN_URL ? httpUrl(process.env.ADMIN_URL, "ADMIN_URL") : "",
stalwartAdminUrls: stalwartServers.adminUrls,
/**
* Say that an Enterprise-only section is Enterprise-only even on an
@@ -314,6 +329,10 @@ export const config = {
* should not suggest they come without the license.
*/
showEnterpriseNotices: bool("SHOW_ENTERPRISE_NOTICES", false),
/** See the note above `config`. Empty keeps the password form. */
oauthClientSecret,
oauthClientId: env("OAUTH_CLIENT_ID", "ihasmail-inbuxa"),
publicUrl,
appSecret,
trustProxy: bool("TRUST_PROXY", true),
/**
@@ -367,7 +386,7 @@ export const config = {
/* 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",
followAdvertisedUrls: process.env.MAIL_SERVER_FOLLOW_ADVERTISED_URLS === "1",
};
export type Config = typeof config;
+2 -2
View File
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "https://default.example";
process.env.MAIL_SERVER_URL = "https://default.example";
const { upstreamFor } = await import("./upstream.js");
const { config } = await import("./config.js");
@@ -9,7 +9,7 @@ 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
* `MAIL_SERVER_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.
*/
+1 -1
View File
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
process.env.APP_SECRET = "test-secret-for-ics-proxy";
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
+1 -1
View File
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { createServer, request as httpRequest, type IncomingMessage, type Server } from "node:http";
import { AddressInfo } from "node:net";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
process.env.APP_SECRET = "test-secret-for-image-proxy";
const { fetchPinned, isPrivateAddress } = await import("./imageproxy.js");
+2 -1
View File
@@ -217,7 +217,8 @@ export async function imageProxyHandler(c: Context) {
res.on("close", done);
const headers = new Headers({
"Content-Type": type,
"Cache-Control": "private, max-age=86400",
// As for attachments: nothing left in the disk cache of a device that is not the person's own.
"Cache-Control": (c.get("session") as { remember?: boolean } | undefined)?.remember ? "private, max-age=86400" : "no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'",
"Cross-Origin-Resource-Policy": "same-origin",
+1 -1
View File
@@ -7,7 +7,7 @@ async function main() {
const app = createApp();
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
console.log(`[ihasmail] ${config.appName} listening on http://${info.address}:${info.port}`);
console.log(`[ihasmail] upstream Stalwart: ${config.stalwartUrl}`);
console.log(`[ihasmail] mail server: ${config.stalwartUrl}`);
console.log(`[ihasmail] static dir: ${config.staticDir}`);
});
+4 -5
View File
@@ -19,7 +19,7 @@ process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-login-guard";
const mock = await import("./mock/index.js");
@@ -48,13 +48,12 @@ test("a server without the registry is refused, with good credentials", async ()
assert.equal(res.body.error, "unsupported_server");
});
test("the message says the credentials were fine, and names the way out", async () => {
test("the message says the credentials were fine, and that the server isn't supported", async () => {
const { body } = await login({ username: "[email protected]", password: "demo-password" });
// Someone hitting this has typed a correct password. Saying so is the
// difference between "upgrade your server" and "try your password again".
// difference between "wrong server" and "try your password again".
assert.match(body.message, /credentials are fine/i);
assert.match(body.message, /0\.16/);
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
assert.match(body.message, /isn't one this webmail supports/);
});
test("no session is minted for a server we cannot talk to", async () => {
+9
View File
@@ -0,0 +1,9 @@
import { account } from "./config.js";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
/* Shared by the HTTP layer and by the handlers that re-check a code. */
export function checkOtp(code: string | undefined): boolean {
if (!account.otpUrl) return true;
const params = parseOtpauthUrl(account.otpUrl);
return Boolean(code && params && verifyTotp(params, code));
}
+51
View File
@@ -0,0 +1,51 @@
import { readFileSync } from "node:fs";
export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../web/src/locales/permissions/source.json", import.meta.url), "utf8")) as { permissions: Array<{ name: string; label: string }> }).permissions;
export const PORT = Number(process.env.MOCK_PORT ?? 8788);
/**
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
* against a server ihasmail does not support. This is only that: the rest of
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
* support for it.
*/
export const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
/**
* Stalwart advertises FUTURERELEASE in the session but only honors it when
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
* off, in which case the hold is dropped without a word and the message goes
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
*/
export const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
/** What the session advertises, matching Stalwart's own 30 days. */
export const MAX_DELAYED_SEND = 86400 * 30;
export const ACCOUNT = "a1";
/** How long a push subscription lives before the server drops it. */
export const PUSH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/** An account somebody has shared with the demo user. See the session below. */
export const SHARED_ACCOUNT = "a2";
export const SHARED_CAPS: Obj = {
"urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {},
"urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {},
};
export const USER = process.env.MOCK_USER ?? "[email protected]";
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
export const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
/** What /api/account reports. Tenants are managed only on "enterprise"; MOCK_EDITION=enterprise to develop them. */
export const MOCK_EDITION = process.env.MOCK_EDITION ?? "oss";
export const PASS = process.env.MOCK_PASS ?? "demo";
/**
* Credential state, mutable so the self-service flows can be exercised against
* the mock the way they run against a real 0.16 server: the password changes,
* 2FA starts demanding a code on every request, and app passwords keep working
* without one.
*/
export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] };
export const MASKED = "[********]";
export type Obj = Record<string, unknown>;
export const state = { n: 1 };
export const nextState = () => String(state.n++);
+410
View File
@@ -0,0 +1,410 @@
import { randomUUID } from "node:crypto";
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
import { Obj, SHARED_ACCOUNT, USER, account } from "./config.js";
/* ---------- data ---------- */
/*
* The names are Stalwart's own defaults, which follow the Exchange convention:
* "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the
* short forms, so anything built from a folder's name read differently here
* than in production -- "Empty Trash" against the mock, "Empty Deleted Items"
* against a real server -- and every screenshot in the README showed a folder
* list no user has. The role is what the client branches on; the name is only
* ever displayed, which is exactly why it has to look right.
*/
/** Push subscriptions, as a fresh account has none. */
export const pushSubscriptions: Obj[] = [];
export const mailboxes: Obj[] = [
mb("inbox", "Inbox", "inbox"),
mb("drafts", "Drafts", "drafts"),
mb("sent", "Sent Items", "sent"),
mb("junk", "Junk Mail", "junk"),
mb("trash", "Deleted Items", "trash"),
mb("archive", "Archive", "archive"),
mb("work", "Work", null),
mb("work-inv", "Invoices", null, "work"),
mb("news", "Newsletters", null),
];
export function mb(id: string, name: string, role: string | null, parentId: string | null = null): Obj {
return { id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true } };
}
export const blobs = new Map<string, { type: string; data: Buffer }>();
export function putBlob(data: Buffer | string, type: string): string {
const id = `b${randomUUID().slice(0, 8)}`;
blobs.set(id, { type, data: Buffer.isBuffer(data) ? data : Buffer.from(data) });
return id;
}
export const people = [
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
["Stalwart Labs", "[email protected]"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
];
export const subjects = [
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
"Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?",
"Reminder: dentist appointment", "Flight confirmation BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in",
];
export const emails: Obj[] = [];
export const seq = { counter: 1 };
/**
* A real TNEF blob, built to the format description, so the winmail.dat
* decoder has something to open that is not a hand-made fixture in its own
* test file. Two files inside, one of them carrying a long name in the MAPI
* stream behind an 8.3 title -- which is the case the decoder exists for.
*/
export function winmailDat(): Buffer {
const u16 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff]);
const u32 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]);
const sum = (b: Buffer) => { let n = 0; for (const x of b) n = (n + x) & 0xffff; return n; };
const attr = (level: number, id: number, data: Buffer) => Buffer.concat([Buffer.from([level]), u32(id), u32(data.length), data, u16(sum(data))]);
const asciiProp = (id: number, value: string) => {
const bytes = Buffer.concat([Buffer.from(value, "latin1"), Buffer.from([0])]);
const pad = Buffer.alloc((4 - (bytes.length % 4)) % 4);
return Buffer.concat([u32(((id & 0xffff) << 16) | 0x001e), u32(bytes.length), bytes, pad]);
};
const mapi = (props: Buffer[]) => Buffer.concat([u32(props.length), ...props]);
const renddata = Buffer.alloc(14);
const title = (n: string) => Buffer.concat([Buffer.from(n, "latin1"), Buffer.from([0])]);
const notes = Buffer.from("Numbers pulled from the mock, not from anywhere real.\n", "latin1");
const csv = Buffer.from("quarter,revenue\nQ1,120\nQ2,145\n", "latin1");
return Buffer.concat([
u32(0x223e9f78), u16(0x1234),
attr(1, 0x00089006, u32(0x00010000)), // attTnefVersion
attr(2, 0x00069002, renddata),
attr(2, 0x00018010, title("QUARTE~1.CSV")),
attr(2, 0x00069005, mapi([asciiProp(0x3707, "Quarterly Revenue Final.csv"), asciiProp(0x370e, "text/csv")])),
attr(2, 0x0006800f, csv),
attr(2, 0x00069002, renddata),
attr(2, 0x00018010, title("notes.txt")),
attr(2, 0x0006800f, notes),
]);
}
/**
* A really signed message, served as the raw blob a client verifies against.
*
* The signature is over exact bytes, so this deliberately does not go through
* addEmail: that builds a message out of parts and would hand back a body it
* had assembled rather than the one that was signed. Here the blob *is* the
* fixture, byte for byte, and the JMAP metadata is arranged around it.
*
* `bodyStructure` says multipart/signed because that is what the client checks
* before deciding to download anything -- a mock that omitted it would leave
* the whole path unreachable while every stored byte was still correct.
*/
export function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string, string]; subject: string; daysAgo: number; mailbox: string; unread?: boolean }) {
const id = `e${seq.counter++}`;
const raw = signedMessage(o.which);
const received = new Date(Date.now() - o.daysAgo * 86400_000).toISOString().replace(/\.\d{3}Z$/, "Z");
const body = "The Analytical Engine has no pretensions whatever to originate anything.";
const textBlob = putBlob(body, "text/plain");
const e: Obj = {
id,
blobId: putBlob(raw, "message/rfc822"),
threadId: `t${id}`,
mailboxIds: { [o.mailbox]: true },
keywords: o.unread ? {} : { $seen: true },
size: raw.length,
receivedAt: received,
sentAt: received,
messageId: [`${id}@mock`],
inReplyTo: null,
references: null,
from: [{ name: o.from[0], email: o.from[1] }],
to: [{ name: "Demo User", email: USER }],
cc: null, bcc: null, replyTo: null, sender: null,
subject: o.subject,
hasAttachment: false,
preview: body.slice(0, 120),
textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
htmlBody: [],
attachments: [],
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
bodyStructure: {
partId: null, blobId: null, size: raw.length, type: "multipart/signed", name: null, charset: null, disposition: null, cid: null,
subParts: [
{ partId: "1", blobId: textBlob, size: body.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null },
{ partId: "2", blobId: null, size: 0, type: "application/x-pkcs7-signature", name: "smime.p7s", charset: null, disposition: "attachment", cid: null },
],
},
};
emails.push(e);
return e;
}
/*
* A marketing template of the shape #290 was reported against.
*
* Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a
* `<style>` block, a colored call to action, a gray footer — and that is the
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
* mock without one could not show what "apply the theme to messages too" does
* to the mail people actually receive: nothing at all.
*/
export const STYLED_MARKETING_HTML = `<html><head><style>
a { color:#1155CC; text-decoration:underline }
.h { font-size:20px; color:#111111 }
</style></head><body style="margin:0;background-color:#f4f4f4">
<table width="100%" bgcolor="#f4f4f4" cellpadding="0" cellspacing="0"><tr><td align="center">
<table width="600" bgcolor="#ffffff" cellpadding="0" cellspacing="0" style="background-color:#ffffff">
<tr><td style="padding:24px"><p class="h">Your order is on its way</p>
<p style="color:#333333">Thanks for shopping with us. Your parcel left the warehouse this morning.</p>
<table cellpadding="0" cellspacing="0"><tr>
<td bgcolor="#1155CC" style="border-radius:4px;padding:12px 20px">
<a href="https://example.com/track" style="color:#FFFFFF;text-decoration:none">Track your parcel</a>
</td></tr></table>
<p style="color:#666666;font-size:12px">Order #4471 &middot; placed 2 September</p>
</td></tr>
<tr><td bgcolor="#222222" style="padding:16px;color:#dddddd;font-size:12px">
You are receiving this because you bought something. <a href="https://example.com/x" style="color:#88bbff">Unsubscribe</a>
</td></tr>
</table>
</td></tr></table></body></html>`;
export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
const id = `e${seq.counter++}`;
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag &amp; drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
const textBlob = putBlob(text, "text/plain");
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
const attachments: Obj[] = [];
if (o.attach) {
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null });
}
if (o.winmail) {
const dat = winmailDat();
attachments.push({ partId: "6", blobId: putBlob(dat, "application/ms-tnef"), size: dat.length, name: "winmail.dat", type: "application/ms-tnef", charset: null, disposition: "attachment", cid: null });
}
if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" });
const e: Obj = {
id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"),
threadId: o.threadId ?? `t${id}`, mailboxIds: { [o.mailbox]: true },
keywords: { ...(o.unread ? {} : { $seen: true }), ...(o.flagged ? { $flagged: true } : {}) },
size: 4000 + Math.floor(Math.random() * 20000), receivedAt: received, sentAt: received,
messageId: [`${id}@mock`], inReplyTo: o.inReplyTo ? [o.inReplyTo] : null, references: o.inReplyTo ? [o.inReplyTo] : null,
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [],
attachments,
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) },
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
// Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so
// delivered mail carries it and mail this account wrote does not.
"header:X-Spam-Status:asText":
o.mailbox === "junk"
? "Yes, score=14.2 required=5.0 tests=[BAYES_99=3.5, URIBL_BLOCKED=2.7, HTML_IMAGE_ONLY=1.4, SUBJ_ALL_CAPS=1.2, FROM_FREEMAIL=0.4] autolearn=no"
: o.mailbox === "inbox"
? "No, score=-1.8 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7, SPF_PASS=-0.1, HTML_MESSAGE=0.9]"
: null,
};
emails.push(e);
return e;
}
// Seed
for (let i = 0; i < 45; i++) {
const p = people[i % people.length]!;
const subj = subjects[i % subjects.length]!;
const e = addEmail({ from: [p[0]!, p[1]!], subject: subj, daysAgo: i * 0.7, mailbox: i % 9 === 8 ? "news" : i % 11 === 10 ? "work" : "inbox", unread: i % 3 === 0, flagged: i % 7 === 0, html: i % 2 === 0, attach: i % 5 === 0 });
if (i % 4 === 0) {
// thread replies
addEmail({ from: ["Demo User", USER], to: p[1]!, subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.2, mailbox: "sent", threadId: e.threadId as string, inReplyTo: `${e.id}@mock`, html: true });
addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 });
}
}
addEmail({ from: ["Shop Updates", "[email protected]"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true });
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
/*
* Three signed messages, so every branch of the signature banner can be seen
* without staging a certificate authority. Read "A note" first: that pins Ada's
* certificate, after which the other two have something to disagree with.
*/
addSignedEmail({ which: "good", from: ["Ada Lovelace", "[email protected]"], subject: "A note", daysAgo: 0.2, mailbox: "inbox", unread: true });
addSignedEmail({ which: "tampered", from: ["Ada Lovelace", "[email protected]"], subject: "A note (altered in transit)", daysAgo: 0.25, mailbox: "inbox", unread: true });
addSignedEmail({ which: "imposter", from: ["Ada Lovelace", "[email protected]"], subject: "A note (signed by somebody else)", daysAgo: 0.3, mailbox: "inbox", unread: true });
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
// A thread whose unread message is not the last one: someone's server queued
// their reply for hours, so it landed after messages that answer it and sits in
// the middle of the conversation. Opening this thread at the newest message
// left that reply above the fold until the mark-read timer swept it (#87).
{
const subj = "Compiler timings for the release";
const t = addEmail({ from: ["Grace Hopper", "[email protected]"], subject: subj, daysAgo: 6, mailbox: "inbox", html: true });
const tid = t.threadId as string;
const reply = (o: { from: [string, string]; daysAgo: number; mailbox: string; to?: string; unread?: boolean; html?: boolean }) =>
addEmail({ ...o, subject: `Re: ${subj}`, threadId: tid, inReplyTo: `${t.id}@mock` });
reply({ from: ["Alan Turing", "[email protected]"], daysAgo: 5.5, mailbox: "inbox", unread: true });
// Long enough after the unread one that the thread scrolls: opening at the
// bottom put four messages between the reader and the mail they had not read.
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 5, mailbox: "sent", html: true });
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 4.5, mailbox: "inbox" });
reply({ from: ["Margaret Hamilton", "[email protected]"], daysAgo: 4, mailbox: "inbox", html: true });
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 3.5, mailbox: "sent" });
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 3, mailbox: "inbox", html: true });
}
// Invitation email
{
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
const e = addEmail({ from: ["Ada Lovelace", "[email protected]"], subject: "Invitation: Project kickoff", daysAgo: 0.3, mailbox: "inbox", unread: true });
const b = putBlob(ics, "text/calendar");
(e.bodyStructure as Obj).subParts = [...((e.bodyStructure as Obj).subParts as Obj[]), { partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }];
(e.attachments as Obj[]).push({ partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null });
e.hasAttachment = true;
}
export const identities: Obj[] = [
{ id: "i1", name: "Demo User", email: USER, replyTo: null, bcc: null, textSignature: "-- \nDemo User\nihasmail", htmlSignature: "<div>-- <br><b>Demo User</b><br>ihasmail</div>", mayDelete: false },
{ id: "i2", name: "Demo (alias)", email: "[email protected]", replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true },
];
export const vacationBox: { current: Obj } = { current: { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null } };
export const sieveScripts: Obj[] = [];
/* A calendar in the shared account, so "Shared with me" and a colleague's
events appearing in the grid can be exercised. Read-only, as a share is. */
export const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
export const sharedEvents: Obj[] = [];
export const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
export const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
export const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
export function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
export const events: Obj[] = [];
{
const now = new Date();
const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; };
const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`;
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
/*
* One event in a zone that is not the reader's, because every other fixture
* here uses the machine's own and so cannot tell a correct conversion from
* no conversion at all. Dragging this one is what proves a move keeps the
* time the event says it happens at.
*/
events.push({ id: "ev9", calendarIds: { c1: true }, "@type": "Event", uid: "ev9", title: "Tokyo sync", start: local(d(2, 15)), timeZone: "Asia/Tokyo", duration: "PT1H", showWithoutTime: false, color: "#7c3aed" });
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
// Two in the shared account, so a colleague's calendar has something in it.
sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null });
}
export const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
export const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write });
export const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights() }];
/* A book in the shared account, so "Shared with me" and addressing a message
from somebody else's contacts can be exercised at all. Read-only, which is
what a share usually is. */
export const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }];
export const sharedCards: Obj[] = [
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
];
/**
* One sort property, as Email/query defines them. `hasKeyword` sorts a
* boolean, and false comes before true -- which is what makes "unread first"
* an *ascending* sort on $seen.
*/
export function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number {
const addr = (v: unknown) => String(((v as Obj[] | undefined)?.[0] as Obj | undefined)?.email ?? "");
switch (property) {
case "receivedAt": return String(x.receivedAt).localeCompare(String(y.receivedAt));
case "sentAt": return String(x.sentAt ?? x.receivedAt).localeCompare(String(y.sentAt ?? y.receivedAt));
case "size": return Number(x.size ?? 0) - Number(y.size ?? 0);
case "subject": return String(x.subject ?? "").localeCompare(String(y.subject ?? ""));
case "from": return addr(x.from).localeCompare(addr(y.from));
case "to": return addr(x.to).localeCompare(addr(y.to));
case "hasKeyword": {
const has = (e: Obj) => (keyword && (e.keywords as Obj | undefined)?.[keyword] ? 1 : 0);
return has(x) - has(y);
}
default: return 0;
}
}
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
export const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
/** The floor Stalwart puts under a requested EventSource ping interval. */
export const PING_FLOOR_SECONDS = 30;
/*
* An account that may not send calendar invitations.
*
* 0.16.21 rejects a `CalendarEvent/set` that asks for scheduling messages when
* the account lacks the `calendarSchedulingSend` permission, rather than
* accepting the write and quietly sending nothing. **Confirmed live on 0.16.21
* (2026-09-06)** against an account holding a role with that permission
* disabled: `sendSchedulingMessages: true` came back `notCreated` with
* `forbidden` and the text below, while the identical request with the flag
* false was created normally. Set MOCK_NO_SCHEDULING_SEND=1 to develop against
* that account.
*/
export const NO_SCHEDULING_SEND = process.env.MOCK_NO_SCHEDULING_SEND === "1";
export const SCHEDULING_FORBIDDEN = "This account is not allowed to send calendar scheduling messages.";
export const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
/** One per contact, by index; a gap means that card has no birthday. */
export const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
{ year: 1815, month: 12, day: 10 },
{ month: 6, day: 9 }, // no year: the common case
{ year: 1912, month: 6, day: 23 },
null,
{ year: 2000, month: 2, day: 29 }, // lands on the 28th in a non-leap year
{ year: 1918, month: 8, day: 26 },
];
export const cards: Obj[] = people.slice(0, 6).map((p, i) => {
const [given, surname] = p[0]!.split(" ");
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined,
/*
* Birthdays on most but not all of them, and one with no year, because a
* card that records only a day and month is the common case rather than
* the exceptional one.
*/
anniversaries: BIRTHDAYS[i] ? { a1: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", ...BIRTHDAYS[i] } } } : undefined };
});
export const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" }));
export const fileNodes: Obj[] = [
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" },
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
];
/* What the shared account holds. Its own nodes, so opening the share in Files
shows something different from the reader's own folders rather than the same
list under another name. */
export const sharedFileNodes: Obj[] = [
{ id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
{ id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
];
/** The node list an account owns. */
export const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes);
export function fr() {
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
}
export function recount() {
for (const m of mailboxes) {
const inBox = emails.filter((e) => (e.mailboxIds as Obj)[m.id as string]);
m.totalEmails = inBox.length;
m.unreadEmails = inBox.filter((e) => !(e.keywords as Obj).$seen).length;
const threads = new Set(inBox.map((e) => e.threadId));
m.totalThreads = threads.size;
m.unreadThreads = new Set(inBox.filter((e) => !(e.keywords as Obj).$seen).map((e) => e.threadId)).size;
}
}
recount();
+1 -1
View File
@@ -226,7 +226,7 @@ export function createDirectory(opts: Options) {
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
}
}
const applications: Obj[] = [{ id: "app1", description: "Stalwart Web Interface", enabled: true, urlPrefix: { "/admin": true, "/account": true } }];
const applications: Obj[] = [{ id: "app1", description: "Web Interface", enabled: true, urlPrefix: { "/admin": true, "/account": true } }];
/** Tenants: a name, limits, and whatever names them in its memberTenantId. */
const tenants: Obj[] = [
+442
View File
@@ -0,0 +1,442 @@
import { randomUUID } from "node:crypto";
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
import { createDirectory, mockRole } from "./directory.js";
import { ACCOUNT, MOCK_LOCALE, Obj, USER, account, nextState, state } from "./config.js";
import { NO_SCHEDULING_SEND, SCHEDULING_FORBIDDEN, blobs, events, mailboxes } from "./data.js";
/* ---------- helpers ---------- */
export function pick(o: Obj, props?: string[] | null): Obj {
if (!props) return o;
const out: Obj = { id: o.id };
for (const p of props) if (p in o) out[p] = o[p];
else if (p.startsWith("header:")) out[p] = null;
return out;
}
export function resolveRefs(args: Obj, responses: [string, Obj, string][], creations: Record<string, string>): Obj {
const out: Obj = {};
for (const [k, v] of Object.entries(args)) {
if (k.startsWith("#")) {
const r = v as { resultOf: string; name: string; path: string };
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
} else out[k] = resolveCreationIds(v, creations, k);
}
return out;
}
/**
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
* go, pointing at something created earlier in the same request. Sending a
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
* without this the mock quietly declines to create any submission at all.
*
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
* the method that receives them resolves them itself.
*/
export function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
if (key === "onSuccessUpdateEmail") return value;
if (typeof value === "string") {
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
}
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
if (value && typeof value === "object") {
const out: Obj = {};
for (const [k, v] of Object.entries(value as Obj)) {
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
out[nk] = resolveCreationIds(v, creations, k);
}
return out;
}
return value;
}
export function jsonPointer(obj: unknown, path: string): unknown {
const parts = path.split("/").filter(Boolean);
let cur: unknown = obj;
for (let i = 0; i < parts.length; i++) {
const p = parts[i]!;
if (p === "*") {
const rest = parts.slice(i + 1).join("/");
const arr = (cur as unknown[]).flatMap((x) => { const v = jsonPointer(x, "/" + rest); return Array.isArray(v) ? v : [v]; });
return arr;
}
cur = (cur as Obj)?.[p];
}
return cur;
}
export function matchFilter(e: Obj, f: Obj | undefined): boolean {
if (!f) return true;
if (f.operator) {
const conds = (f.conditions as Obj[]).map((c) => matchFilter(e, c));
return f.operator === "AND" ? conds.every(Boolean) : f.operator === "OR" ? conds.some(Boolean) : !conds.some(Boolean);
}
const kw = e.keywords as Obj;
if (f.inMailbox && !(e.mailboxIds as Obj)[f.inMailbox as string]) return false;
if (f.hasKeyword && !kw[f.hasKeyword as string]) return false;
if (f.notKeyword && kw[f.notKeyword as string]) return false;
if (f.hasAttachment !== undefined && Boolean(e.hasAttachment) !== f.hasAttachment) return false;
const hay = `${e.subject} ${JSON.stringify(e.from)} ${JSON.stringify(e.to)} ${e.preview}`.toLowerCase();
for (const k of ["text", "subject", "from", "to", "body"]) if (f[k] && !hay.includes(String(f[k]).toLowerCase())) return false;
if (f.before && String(e.receivedAt) >= String(f.before)) return false;
if (f.after && String(e.receivedAt) < String(f.after)) return false;
if (f.minSize && Number(e.size) < Number(f.minSize)) return false;
if (f.maxSize && Number(e.size) > Number(f.maxSize)) return false;
return true;
}
export function applyPatch(obj: Obj, patch: Obj) {
for (const [k, v] of Object.entries(patch)) {
if (k.includes("/")) {
const [root, ...rest] = k.split("/");
const key = rest.join("/");
const target = (obj[root!] as Obj) ?? {};
if (v === null) delete target[key];
else target[key] = v;
obj[root!] = target;
} else obj[k] = v;
}
}
/* ---------- method handlers ---------- */
export type Handler = (args: Obj) => Obj | [string, Obj][];
/** A method-level failure, surfaced as ["error", {type, description}, id]. */
export class MethodError extends Error {
constructor(
public readonly type: string,
description?: string,
) {
super(description ?? type);
}
}
export const MAX_OBJECTS = 500;
/**
* Stalwart refuses a whole method call that carries more objects than it will
* process at once - it does not quietly handle the first 500. Enforce the same
* ceiling the session advertises, so an unbatched client fails here too.
*/
export function enforceLimits(name: string, args: Obj): void {
const tooLarge = () => {
throw new MethodError("requestTooLarge", "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call.");
};
if (name.endsWith("/get")) {
const ids = args.ids as unknown[] | null | undefined;
if (Array.isArray(ids) && ids.length > MAX_OBJECTS) tooLarge();
}
if (name.endsWith("/set")) {
const n =
Object.keys((args.create as Obj) ?? {}).length +
Object.keys((args.update as Obj) ?? {}).length +
((args.destroy as unknown[] | undefined)?.length ?? 0);
if (n > MAX_OBJECTS) tooLarge();
}
}
export const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
/*
* `Mailbox/get` does not return `shareWith` unless a client asks for it by
* name: a `/get` with no `properties` comes back without the field at all.
* Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
* The mock handing it over unasked meant a client that never asked still saw
* every share, and the one place that did not -- the real server -- showed
* nothing shared at all.
*
* Calendars and address books used to behave the same way and no longer do.
* 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property
* when `properties` is omitted or null, `shareWith` included. **Confirmed live
* on 0.16.21 (2026-09-06):** both come back with the full set, while
* `Mailbox/get` on the same server still omits it — so this stays, and it
* stays applied to mailboxes alone.
*/
export function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
if (a.properties) return res;
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
}
export function genericGet(list: Obj[]) {
return (a: Obj) => {
const ids = a.ids as string[] | null | undefined;
const found = ids ? ids.map((id) => list.find((x) => x.id === id)).filter(Boolean) as Obj[] : list;
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
};
}
/**
* An id, as either a stored event or one occurrence of one.
*
* A synthetic id whose base is gone, or whose date the rule no longer
* generates (excluded, or past a `count`), resolves to nothing — `notFound`,
* the way the server answers for an occurrence that is not there any more.
*/
export function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
const direct = list.find((x) => x.id === id);
if (direct) return { base: direct };
const parsed = parseSyntheticId(id);
if (!parsed) return null;
const base = list.find((x) => x.id === parsed.baseId);
if (!base) return null;
const occ = occurrenceAt(base, parsed.recurrenceId);
return occ ? { base, occ } : null;
}
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
export class SetError extends Error {
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; }
}
export function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
return (a: Obj) => {
const created: Obj = {};
const updated: Obj = {};
const destroyed: string[] = [];
const notCreated: Obj = {};
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const id = `${prefix}${randomUUID().slice(0, 6)}`;
const o = { ...(obj as Obj), id };
try {
onCreate?.(o);
} catch (err) {
if (!(err instanceof SetError)) throw err;
notCreated[cid] = err.toJSON();
continue;
}
list.push(o);
created[cid] = { id };
}
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
const o = list.find((x) => x.id === id);
if (o) { applyPatch(o, patch as Obj); updated[id] = null; }
}
for (const id of (a.destroy as string[]) ?? []) {
const i = list.findIndex((x) => x.id === id);
if (i >= 0) { list.splice(i, 1); destroyed.push(id); }
}
return setResp({ created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}) });
};
}
/* ---------- calendar events ---------- */
/**
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
*
* An update or destroy aimed at an occurrence does not touch the series: it
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
*
* The refusals are the point of reproducing this at all:
*
* - a base event and one of its instances in the same request is refused, both
* ids at once, because the server cannot apply them in a defined order;
* - the same id twice is "Duplicate event id.";
* - the ten event-level properties are refused with `invalidProperties`;
* - and the twelve inherited ones are dropped in silence, with the response
* still saying the update succeeded. A mock that applied them would let a
* client that sends them look correct everywhere except a real server.
*/
/**
* Enough of an iCalendar reader to stand in for Stalwart's.
*
* It reads per VEVENT rather than across the whole file, because a file is the
* case an emailed invitation never was: an export carries a year of them, and a
* regex over the whole text would find the first DTSTART and call that the
* answer. One event still comes back as a bare object, the shape this returned
* when an invitation was all it had to handle.
*
* The synthetic organizer and attendee only go on events that arrived with a
* METHOD. Those are scheduling messages, which is what the invitation fixtures
* are; a plain export is not addressed to anyone, and inventing participants
* for it would make imported events look like invitations nobody sent.
*/
export function calendarEventParse(a: Obj) {
const parsed: Obj = {};
const notParsable: string[] = [];
for (const b of a.blobIds as string[]) {
const blob = blobs.get(b);
if (!blob) { notParsable.push(b); continue; }
const text = blob.data.toString();
const field = (src: string, k: string) => new RegExp(`^${k}[^:\r\n]*:(.*)$`, "m").exec(src)?.[1]?.trim();
const method = field(text, "METHOD");
const bodies = text.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) ?? [];
const events = bodies.map((body) => {
const g = (k: string) => field(body, k);
const ds = g("DTSTART") ?? "20260101T000000Z";
const de = g("DTEND") ?? ds;
const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`;
const start = new Date(`${toLocal(ds)}Z`);
const end = new Date(`${toLocal(de)}Z`);
return {
"@type": "Event",
uid: g("UID"),
title: g("SUMMARY"),
start: toLocal(ds),
timeZone: "Etc/UTC",
duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`,
method,
locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined,
participants: method
? {
org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } },
me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" },
}
: undefined,
};
});
if (!events.length) { notParsable.push(b); continue; }
parsed[b] = events.length === 1 ? events[0] : events;
}
return { accountId: ACCOUNT, parsed, notParsable };
}
export function calendarEventSet(a: Obj) {
const created: Obj = {};
const updated: Obj = {};
const destroyed: string[] = [];
const notCreated: Obj = {};
const notUpdated: Obj = {};
const notDestroyed: Obj = {};
/*
* An account that may not send invitations refuses the whole request the
* moment it asks for them, and refuses it per object rather than as a method
* error. Confirmed live on 0.16.21 for all three of create, update and
* destroy; the same requests with the flag absent or false went through.
* The flag alone decides it — the server does not first check whether the
* event has anyone to notify.
*/
if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) {
const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON();
for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied();
for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied();
for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied();
return setResp({
created, updated, destroyed,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
});
}
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
// participants addressed the RFC 8984 way. The mock did neither, which is
// how #26 and #30 reached a live server unnoticed — so it does both.
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
const parts = o.participants as Record<string, Obj> | undefined;
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
o.uid = o.uid ?? randomUUID();
events.push(o);
created[cid] = { id: o.id };
}
const updates = Object.entries((a.update as Obj) ?? {});
const destroys = ((a.destroy as string[]) ?? []).slice();
const seen = new Set<string>();
/* A base and one of its instances cannot be settled in the same request. */
const baseOf = (id: string): string | null => {
const r = resolveEvent(events, id);
return r ? (r.base.id as string) : null;
};
const touched = new Map<string, { base: string[]; instance: string[] }>();
for (const id of [...updates.map(([id]) => id), ...destroys]) {
const b = baseOf(id);
if (!b) continue;
const entry = touched.get(b) ?? { base: [], instance: [] };
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
touched.set(b, entry);
}
const conflicted = new Set<string>();
for (const [, e] of touched) {
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
}
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
for (const [id, patch] of updates) {
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
seen.add(id);
const resolved = resolveEvent(events, id);
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
writeOverride(resolved.base, resolved.occ, applied);
updated[id] = null;
}
for (const id of destroys) {
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
const resolved = resolveEvent(events, id);
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
if (resolved.occ) {
// One date off a series, which is an override rather than a deletion.
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
destroyed.push(id);
continue;
}
const i = events.findIndex((x) => x.id === id);
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
}
return setResp({
created, updated, destroyed,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
});
}
/**
* Merge a patch into the override for one date.
*
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
* an override always carries its own timing; the mock does the same, or a
* client could depend on inheriting them and be right only here.
*/
export function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
const next: Obj = { ...existing };
if (!replace) {
if (!("start" in next)) next.start = occ.start;
if (!("duration" in next) && base.duration) next.duration = base.duration;
}
applyPatch(next, patch);
overrides[occ.recurrenceId] = next;
base.recurrenceOverrides = overrides;
}
/* ---------- submissions ---------- */
/**
* Held messages, the way Stalwart models them: `sendAt` is derived from the
* envelope's FUTURERELEASE parameter rather than set by the client, and
* `undoStatus` reports whether the message is still in the queue.
*/
export const submissions: Obj[] = [];
export function submissionView(sub: Obj): Obj {
return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) };
}
export function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
if (!f) return true;
if (f.undoStatus && undoStatusOf(sub, Date.now()) !== f.undoStatus) return false;
if (Array.isArray(f.emailIds) && !(f.emailIds as string[]).includes(sub.emailId as string)) return false;
if (Array.isArray(f.identityIds) && !(f.identityIds as string[]).includes(sub.identityId as string)) return false;
return true;
}
/** Who the demo user is, for administration. See mock/directory.ts. */
export const directory = createDirectory({
accountId: ACCOUNT,
user: USER,
locale: MOCK_LOCALE,
role: mockRole(process.env.MOCK_ROLE),
metricsOff: process.env.MOCK_METRICS === "off",
fail: (type, description) => new MethodError(type, description),
});
+36
View File
@@ -0,0 +1,36 @@
import type { ServerResponse } from "node:http";
import { ACCOUNT, state } from "./config.js";
/*
* The server-sent-events fan-out and the Email/changes ring buffer.
*
* Separate from index.ts because the JMAP handlers raise these events and
* index.ts imports the handlers -- leaving them in index.ts makes that a
* cycle. Separate from data.ts because a live HTTP response is not fixture
* data.
*/
export const sseClients = new Set<ServerResponse>();
/** What changed and when, so `Email/changes` can answer honestly. */
export const emailChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
export function recordEmailChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
emailChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
// A window is plenty; the client refetches from scratch if it falls behind.
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
}
/** The same for contact cards, so `ContactCard/changes` can answer too. */
export const cardChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
/** Changes at or below this state have been dropped from the log, so a client that far behind cannot be answered. */
export const cardLog = { floor: 0 };
export function recordCardChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
cardChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
if (cardChanges.length > 200) {
const dropped = cardChanges.splice(0, cardChanges.length - 200);
cardLog.floor = dropped[dropped.length - 1]!.state;
}
}
export function broadcast(types: string[]) {
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
for (const c of sseClients) c.write(payload);
}
+516
View File
@@ -0,0 +1,516 @@
import { checkOtp } from "./auth.js";
import { cardChanges, cardLog, emailChanges, recordCardChange, recordEmailChange, broadcast } from "./events.js";
import { randomUUID } from "node:crypto";
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
import { ACCOUNT, MASKED, MAX_DELAYED_SEND, MOCK_LOCALE, NO_FUTURE_RELEASE, Obj, PUSH_TTL_MS, SHARED_ACCOUNT, account, nextState, state } from "./config.js";
import { NO_KEYWORD_SORT, abRights, blobs, booksFor, calendarsFor, cards, compareBy, emails, eventsFor, fileNodes, fr, identities, mailboxes, mb, nodesFor, participantIdentities, principals, pushSubscriptions, putBlob, recount, rightsCal, seq, sharedCards, sieveScripts, vacationBox } from "./data.js";
import { Handler, MethodError, applyPatch, calendarEventParse, calendarEventSet, directory, genericGet, genericSet, hideShareWithUnlessAsked, matchFilter, matchSubmissionFilter, pick, resolveEvent, setResp, submissionView, submissions } from "./engine.js";
/** Stalwart's limit per account (0.16.22). */
const MAX_PUSH_SUBSCRIPTIONS = 15;
/** What an empty or missing `types` list is taken to mean: everything. */
const ALL_PUSH_TYPES = ["Email", "EmailDelivery", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse", "CalendarEvent", "Calendar", "ContactCard", "AddressBook", "FileNode", "Quota", "SieveScript", "PushSubscription"];
export const handlers: Record<string, Handler> = {
// 0.16 exposes the account locale here, under a permission ordinary users
// actually have (unlike x:Account below, which needs sysAccountGet).
"x:AccountSettings/get": (a) => {
const ids = (a.ids as string[] | null) ?? ["singleton"];
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
},
// Stalwart's directory registry: accounts, domains and roles, behind the
// same permissions as the real thing. The locale fallback reads x:Account
// too, and is refused here exactly when a real server would refuse it.
...directory.handlers,
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
"Email/query": (a) => {
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
/*
* Honor the sort rather than always answering newest-first. This used to
* ignore it entirely, which reproduced a server that silently returns a
* different order from the one asked for -- the one shape of wrongness a
* client cannot detect.
*/
const sort = (a.sort as Obj[] | undefined) ?? [{ property: "receivedAt", isAscending: false }];
if (NO_KEYWORD_SORT && sort.some((c) => String(c.property) === "hasKeyword")) {
// A method-level failure, the way a real server refuses an optional sort:
// the whole call fails rather than the sort being quietly dropped.
throw new MethodError("unsupportedSort", "Sorting on hasKeyword is not supported.");
}
list.sort((x, y) => {
for (const c of sort) {
const asc = c.isAscending !== false;
const cmp = compareBy(x, y, String(c.property), c.keyword as string | undefined);
if (cmp !== 0) return asc ? cmp : -cmp;
}
return 0;
});
if (a.collapseThreads) {
const seen = new Set<string>();
list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; });
}
const pos = Number(a.position ?? 0);
const limit = Number(a.limit ?? 50);
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit };
},
"Email/get": (a) => genericGet(emails)(a),
/*
* Real changes, not an empty answer.
*
* This used to return three empty arrays whatever had happened, so the
* client's whole reconciliation path -- `Email/changes`, then deciding what
* to do with what came back -- never ran against the mock. A bug living in
* that path could not be reproduced here at all, which is how one reached
* production and survived being "fixed" once (#100). The log below is what
* the real server can answer from.
*/
"Email/changes": (a) => {
const since = Number(a.sinceState ?? 0);
const relevant = emailChanges.filter((c) => c.state > since);
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
return { accountId: ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
},
"Email/set": (a) => {
const r = genericSet(emails, "e", (o) => {
const bv = (o.bodyValues as Record<string, { value: string }>) ?? {};
const walk = (p: Obj | undefined, acc: Obj[]) => { if (!p) return; if (p.partId && bv[p.partId as string]) acc.push({ ...p, blobId: putBlob(bv[p.partId as string]!.value, p.type as string), size: bv[p.partId as string]!.value.length }); (p.subParts as Obj[] | undefined)?.forEach((s) => walk(s, acc)); };
const parts: Obj[] = [];
walk(o.bodyStructure as Obj, parts);
o.textBody = parts.filter((p) => p.type === "text/plain");
o.htmlBody = parts.filter((p) => p.type === "text/html");
o.attachments = [];
const collect = (p: Obj | undefined) => { if (!p) return; if (p.blobId && !p.partId && p.type !== "multipart/mixed") (o.attachments as Obj[]).push({ ...p, size: p.size ?? 0 }); (p.subParts as Obj[] | undefined)?.forEach(collect); };
collect(o.bodyStructure as Obj);
o.hasAttachment = (o.attachments as Obj[]).length > 0;
o.threadId = o.inReplyTo ? (emails.find((e) => (e.messageId as string[] | null)?.[0] === (o.inReplyTo as string[])[0])?.threadId ?? `t${o.id}`) : `t${o.id}`;
o.receivedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
o.size = 2000;
o.preview = (bv.text?.value ?? "").slice(0, 100);
o.messageId = [`${o.id}@mock`];
o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822");
})(a);
recount();
nextState();
recordEmailChange({
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
updated: Object.keys((a.update as Obj) ?? {}),
destroyed: (r.destroyed as string[] | undefined) ?? [],
});
/* A real server pushes a state change after a set, and the client acts on
it -- `Email/changes` runs and the store reconciles what came back. The
mock stayed silent, so that whole path never ran here and a bug living
in it could not be reproduced: marking a message read went round the
server and back on the live instance, and did nothing at all on the mock
(#100). Announced now, the way Stalwart does. */
broadcast(["Email", "Mailbox", "Thread"]);
return r;
},
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${seq.counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
"Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; },
// Stalwart 0.16 registry objects backing self-service credentials.
"x:AccountPassword/get": () => ({
accountId: ACCOUNT,
state: String(state.n),
list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }],
notFound: [],
}),
"x:AccountPassword/set": (a) => {
const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined;
if (!patch) return setResp({ updated: {} });
const current = patch.currentSecret as string | undefined;
const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined;
if (!current) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } });
}
if (current !== account.password) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
}
if (account.otpUrl && !code) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } });
}
if (account.otpUrl && !checkOtp(code!)) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
}
const secret = patch.secret as string | undefined;
if (secret !== undefined && secret !== MASKED) {
if (secret.length < 8) {
return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } });
}
account.password = secret;
}
if ("otpAuth/otpUrl" in patch) {
const url = patch["otpAuth/otpUrl"] as string | null;
if (url !== MASKED) account.otpUrl = url;
}
state.n++;
return setResp({ updated: { singleton: null } });
},
/*
* Push subscriptions. The JMAP half can be modeled; delivery cannot -- that
* runs through the browser vendor's real push service, so nothing local will
* ever make a notification appear.
*
* What is worth reproducing is the handshake, because it is the part that
* fails quietly: a subscription is created unverified and stays silent until
* the client echoes back a code the server pushed. A mock that marked one
* verified on creation would let a client ship without ever implementing
* that, and the symptom in production is "registered, and no notifications".
*/
"PushSubscription/get": (a) => {
const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string);
const list = pushSubscriptions.filter((s) => ids.includes(s.id as string));
// `keys` is write-only in JMAP: the server never hands it back.
return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) };
},
"PushSubscription/set": (a) => {
const created: Obj = {};
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
const destroyed: string[] = [];
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const o = obj as Obj;
const keys = (o.keys ?? {}) as Obj;
// Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push
// API produces; padding it would be the client inventing a shape.
for (const k of ["p256dh", "auth"]) {
const v = String(keys[k] ?? "");
if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; }
if (v.includes("=") || v.includes("+") || v.includes("/")) {
notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` };
break;
}
}
if (notCreated[cid]) continue;
if (!String(o.url ?? "").startsWith("https://")) {
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
continue;
}
// A filter condition with a null value is not a filter -- the real server
// answers "Invalid filter" and refuses the whole subscription. ihasmail
// shipped `inMailbox: null` meaning "the inbox", which meant nothing at
// all here, and the mock accepted it happily. It does not any more.
const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => {
const f = ((cfg as Obj)?.filter ?? {}) as Obj;
return Object.values(f).some((v) => v === null || v === undefined);
});
if (badFilter) {
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
continue;
}
/*
* As Stalwart does (checked live on 0.16.22, 2026-09-16): a repeated
* deviceClientId is a second subscription, not a replacement -- this mock
* used to replace, which is how the client's pile-up never showed here
* (#375) -- and an account holds at most fifteen.
*/
const deviceId = String(o.deviceClientId ?? "");
if (pushSubscriptions.length >= MAX_PUSH_SUBSCRIPTIONS) {
notCreated[cid] = { type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." };
continue;
}
const id = `ps${randomUUID().slice(0, 6)}`;
/*
* A subscription expires, and this used to hand back `expires: null`.
* That is the one shape that makes the client's real problem invisible in
* development: JMAP puts a ceiling of seven days on a push subscription
* and expects the client to re-register before it lapses, so a client
* that never renews works perfectly against a mock that never expires
* anything and goes silent a week after being deployed. Seven days here,
* so "does this client renew?" is a question the mock can answer.
*/
const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString();
// An empty or missing list means every type, not none.
const types = Array.isArray(o.types) && o.types.length ? o.types : ALL_PUSH_TYPES;
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
created[cid] = { id, expires };
state.n++;
}
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
const s = pushSubscriptions.find((x) => x.id === id);
if (!s) { notUpdated[id] = { type: "notFound" }; continue; }
const code = (patch as Obj).verificationCode;
if (code !== undefined) {
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
s.verified = true;
}
// An expiry can be extended, up to the same seven days a new one gets.
const wanted = (patch as Obj).expires;
if (typeof wanted === "string") {
const at = Math.min(Date.parse(wanted), Date.now() + PUSH_TTL_MS);
if (Number.isNaN(at)) { notUpdated[id] = { type: "invalidProperties", properties: ["expires"] }; continue; }
s.expires = new Date(at).toISOString();
}
updated[id] = null;
state.n++;
}
for (const id of (a.destroy as string[]) ?? []) {
const i = pushSubscriptions.findIndex((x) => x.id === id);
if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; }
}
return setResp({ created, notCreated, updated, notUpdated, destroyed });
},
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
"x:AppPassword/set": (a) => {
const created: Obj = {};
const destroyed: string[] = [];
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const id = `ap${randomUUID().slice(0, 6)}`;
// Real app passwords carry their credential id, so the server can spot
// one by its shape alone. Mirror that.
const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`;
const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret };
account.appPasswords.push(row);
created[cid] = { id, secret, createdAt: row.createdAt };
}
for (const id of (a.destroy as string[]) ?? []) {
const i = account.appPasswords.findIndex((x) => x.id === id);
if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); }
}
state.n++;
return setResp({ created, destroyed });
},
"Identity/get": genericGet(identities),
"Identity/set": (a) => {
// Stalwart's cap is `value.len() < 2048` on a Rust string: 2047 bytes of
// UTF-8, not characters. Anything longer is refused by name.
for (const [where, entries] of [["notCreated", (a.create as Obj) ?? {}], ["notUpdated", (a.update as Obj) ?? {}]] as const) {
for (const [key, obj] of Object.entries(entries)) {
const over = ["htmlSignature", "textSignature"].find((prop) => {
const v = (obj as Obj)[prop];
return typeof v === "string" && Buffer.byteLength(v, "utf8") > 2047;
});
if (over) return setResp({ [where]: { [key]: { type: "invalidProperties", properties: [over], description: "Invalid property." } } });
}
}
return genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o }))(a);
},
"EmailSubmission/get": (a) => {
const ids = a.ids as string[] | null | undefined;
const found = ids ? ids.map((id) => submissions.find((x) => x.id === id)).filter(Boolean) as Obj[] : submissions;
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(submissionView(x), a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !submissions.some((x) => x.id === id)) : [] };
},
"EmailSubmission/query": (a) => {
const list = submissions.filter((s) => matchSubmissionFilter(s, a.filter as Obj | undefined));
list.sort((x, y) => String(x.sendAt).localeCompare(String(y.sendAt)));
const pos = Number(a.position ?? 0);
const limit = Number(a.limit ?? 50);
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((s) => s.id), total: list.length, limit };
},
"EmailSubmission/set": (a) => {
const created: Obj = {};
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
const sub = raw as Obj;
const emailId = sub.emailId as string;
const e = emails.find((x) => x.id === emailId);
if (!e) {
notCreated[cid] = { type: "invalidProperties", properties: ["emailId"], description: "Blob for email not found." };
continue;
}
const hold = holdUntilOf(sub.envelope as Obj | undefined, Date.now());
if (Number.isNaN(hold)) {
notCreated[cid] = { type: "invalidProperties", properties: ["envelope"], description: "Failed to parse mailFrom parameters." };
continue;
}
// Stalwart rejects MAIL FROM outright past its own limit.
if (hold !== null && hold > Date.now() + MAX_DELAYED_SEND * 1000) {
notCreated[cid] = { type: "forbiddenMailFrom", description: `Server rejected MAIL-FROM: 501 5.5.4 Requested release time exceeds maximum of ${new Date(Date.now() + MAX_DELAYED_SEND * 1000).toISOString()}.` };
continue;
}
// With the MTA extension off, the hold is dropped in silence.
const sendAt = hold !== null && !NO_FUTURE_RELEASE ? hold : Date.now();
const rec: Obj = {
id: `s${randomUUID().slice(0, 6)}`,
identityId: sub.identityId ?? null,
emailId,
threadId: e.threadId ?? null,
envelope: sub.envelope ?? null,
sendAt: new Date(sendAt).toISOString(),
undoStatus: null,
deliveryStatus: null,
};
submissions.push(rec);
created[cid] = { id: rec.id, sendAt: rec.sendAt, undoStatus: undoStatusOf(rec, Date.now()) };
const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined;
if (patch) applyPatch(e, patch);
}
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
const patch = raw as Obj;
const sub = submissions.find((x) => x.id === id);
if (!sub) { notUpdated[id] = { type: "notFound" }; continue; }
if (patch.undoStatus !== "canceled") {
notUpdated[id] = { type: "invalidProperties", properties: ["undoStatus"], description: "Only cancellation is supported." };
continue;
}
const status = undoStatusOf(sub, Date.now());
if (status !== "pending") {
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already canceled." : "The message has already been sent." };
continue;
}
sub.undoStatus = "canceled";
updated[id] = null;
}
recount();
return setResp({
created,
updated,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
});
},
"VacationResponse/get": () => ({ accountId: ACCOUNT, state: "1", list: [vacationBox.current], notFound: [] }),
"VacationResponse/set": (a) => { const p = ((a.update as Obj) ?? {}).singleton as Obj | undefined; if (p) vacationBox.current = { ...vacationBox.current, ...p }; return setResp({ updated: { singleton: null } }); },
"Quota/get": () => ({ accountId: ACCOUNT, state: "1", list: [{ id: "q1", resourceType: "octets", used: 734003200, hardLimit: 2147483648, scope: "account", name: "Storage", types: ["Email"] }], notFound: [] }),
"SieveScript/get": genericGet(sieveScripts),
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
/*
* With `expandRecurrences` every id that comes back is synthetic — a one-off
* included, which is what a live 0.16.19 does and what makes `baseEventId`
* useless as a test for a series. Without it (the `findByUid` path) the
* stored ids come back untouched, because callers hand those straight to a
* destroy and mean the whole event.
*/
"CalendarEvent/query": (a) => {
const list = eventsFor(a.accountId);
const filter = (a.filter as Obj) ?? {};
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
if (!a.expandRecurrences) {
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
}
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
const ids: string[] = [];
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId));
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
},
"CalendarEvent/get": (a) => {
const list = eventsFor(a.accountId);
const ids = a.ids as string[] | null | undefined;
const properties = a.properties as string[] | null | undefined;
// With no ids every event comes back under its stored id, none synthetic.
if (!ids) return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => eventGetView(x, false, properties)), notFound: [] };
const found: Obj[] = [];
const notFound: string[] = [];
for (const id of ids) {
const resolved = resolveEvent(list, id);
if (!resolved) { notFound.push(id); continue; }
found.push(resolved.occ ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties));
}
return { accountId: ACCOUNT, state: String(state.n), list: found, notFound };
},
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
// participants addressed the RFC 8984 way. The mock did neither, which is how
// #26 and #30 reached a live server unnoticed — so it now does both.
"CalendarEvent/set": (a) => calendarEventSet(a),
"CalendarEvent/parse": (a) => calendarEventParse(a),
"ParticipantIdentity/get": genericGet(participantIdentities),
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
"Principal/get": genericGet(principals),
// One busy block a day across whatever range was asked for. It used to answer
// with a single block on the first day whatever the range, which was all an
// availability bar a day wide could show -- and left a bar covering several
// days looking as though everyone were free for all but the first of them.
"Principal/getAvailability": (a) => {
const from = new Date(String(a.utcStart));
const to = new Date(String(a.utcEnd));
const list: Obj[] = [];
for (let day = new Date(from); day < to && list.length < 31; day.setUTCDate(day.getUTCDate() + 1)) {
const date = day.toISOString().slice(0, 11);
list.push({ utcStart: `${date}13:00:00Z`, utcEnd: `${date}14:30:00Z`, busyStatus: "confirmed", event: null });
}
return { accountId: ACCOUNT, list };
},
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
"AddressBook/set": (a) => {
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
included -- "You are not allowed to modify this address book", confirmed
live on 0.16.19 (2026-08-27) from the account holding the share. A mock
that accepted it would have agreed that subscribing works, which is
exactly the belief that shipped. Calendars accept the same write; the
difference is the server's, not ours. */
if (a.accountId === SHARED_ACCOUNT && a.update) {
const notUpdated: Obj = {};
for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." };
return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated };
}
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
},
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
// An empty `properties` list returns `id` alone, which `pick` already does.
// 0.16.22 made Stalwart agree; through 0.16.21 it returned every property.
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
/*
* Recorded and announced like Email/set, so the client's incremental sync
* (`ContactCard/changes`, then fetching what it names) runs here too. A
* state older than the log's window cannot be answered, as on a real server.
*/
"ContactCard/set": (a) => {
/*
* Stalwart refuses a `blobId` inside `media` (0.16.22, checked live on
* 2026-09-16), and takes the whole call down for it. The mock took
* anything, which is how ihasmail shipped a photo upload that never
* worked against the real server (#376).
*/
const withBlobMedia = (o: unknown) => Object.values(((o as Obj)?.media as Record<string, Obj> | null) ?? {}).some((m) => m && "blobId" in m);
const refuse = { type: "invalidProperties", description: "blobIds in media is not supported.", properties: ["media"] };
const create = { ...((a.create as Obj) ?? {}) };
const update = { ...((a.update as Obj) ?? {}) };
const notCreated: Obj = {};
const notUpdated: Obj = {};
for (const [k, v] of Object.entries(create)) if (withBlobMedia(v)) { notCreated[k] = refuse; delete create[k]; }
for (const [k, v] of Object.entries(update)) if (withBlobMedia(v)) { notUpdated[k] = refuse; delete update[k]; }
const r = genericSet(cards, "cc")({ ...a, create, update });
if (Object.keys(notCreated).length) r.notCreated = { ...((r.notCreated as Obj) ?? {}), ...notCreated };
if (Object.keys(notUpdated).length) r.notUpdated = notUpdated;
nextState();
recordCardChange({
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
updated: Object.keys((r.updated ?? {}) as Obj),
destroyed: (r.destroyed as string[] | undefined) ?? [],
});
broadcast(["ContactCard"]);
return r;
},
"ContactCard/changes": (a) => {
const since = Number(a.sinceState ?? 0);
if (since < cardLog.floor) throw new MethodError("cannotCalculateChanges", "That state is too old to answer from.");
const relevant = cardChanges.filter((c) => c.state > since);
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
return { accountId: a.accountId ?? ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
},
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"FileNode/query": (a) => {
const f = (a.filter as Obj) ?? {};
const fileNodes = nodesFor(a.accountId);
// `nodeType` is a filter 0.16.19 really applies -- checked live on
// 2026-08-27, where it returned the two directories out of seven nodes. The
// mock ignoring it was worse than not having it: the sidebar tree asks for
// directories and was handed files, which it then drew as folders.
const list = fileNodes.filter((n) => {
if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false;
if (f.nodeType && n.nodeType !== f.nodeType) return false;
return true;
});
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
},
"FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a),
"FileNode/set": (a) => {
return genericSet(nodesFor(a.accountId), "f", (o) => {
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
// Without nodeType, a node is a directory precisely when it carries no
// file properties. Keep it internally so query and get stay consistent.
if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory";
})(a);
},
};
+39 -1365
View File
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
/**
* The mock's OAuth side, enough to sign in the way INBUXA's server does:
* metadata, a sign-in page, and a token endpoint for one confidential client.
*
* The sign-in page approves the demo user at once: there is no form, since
* what's being exercised is ihasmail's side of the flow. Tokens are tied to
* the password they were issued under, so a password change revokes them,
* as it does on the real server.
*/
import { createHash, randomBytes } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { PORT, USER, account } from "./config.js";
export const OAUTH_CLIENT_ID = process.env.MOCK_OAUTH_CLIENT_ID ?? "ihasmail-inbuxa";
export const OAUTH_CLIENT_SECRET = process.env.MOCK_OAUTH_CLIENT_SECRET ?? "mock-oauth-secret";
/** Seconds an access token lasts. */
export let accessTokenTtl = Number(process.env.MOCK_OAUTH_TOKEN_TTL ?? 3600);
interface Grant { password: string }
const codes = new Map<string, { challenge: string; redirectUri: string; issuedAt: number }>();
const accessTokens = new Map<string, Grant & { expiresAt: number }>();
const refreshTokens = new Map<string, Grant>();
const base = () => `http://127.0.0.1:${PORT}`;
/** For tests: how long new access tokens last, and a way to end every token. */
export const oauthMock = {
setAccessTokenTtl(seconds: number) { accessTokenTtl = seconds; },
expireAccessTokens() { for (const t of accessTokens.values()) t.expiresAt = 0; },
reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; },
};
/** A bearer token the mock issued, still valid under the current password. */
export function checkBearer(header: string): boolean {
if (!header.startsWith("Bearer ")) return false;
const t = accessTokens.get(header.slice(7));
return Boolean(t && t.expiresAt > Date.now() && t.password === account.password);
}
function json(res: ServerResponse, status: number, body: unknown) {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body));
}
function readForm(req: IncomingMessage): Promise<URLSearchParams> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString())));
});
}
function issue(res: ServerResponse, refresh: string | null) {
const access = `mock-at-${randomBytes(16).toString("hex")}`;
accessTokens.set(access, { password: account.password, expiresAt: Date.now() + accessTokenTtl * 1000 });
const body: Record<string, unknown> = { access_token: access, token_type: "bearer", expires_in: accessTokenTtl };
if (!refresh) {
const fresh = `mock-rt-${randomBytes(16).toString("hex")}`;
refreshTokens.set(fresh, { password: account.password });
body.refresh_token = fresh;
}
return json(res, 200, body);
}
/** Handles the OAuth routes; false for anything else. */
export async function handleOAuth(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
json(res, 200, {
issuer: base(),
authorization_endpoint: `${base()}/login`,
token_endpoint: `${base()}/auth/token`,
grant_types_supported: ["authorization_code", "refresh_token"],
response_types_supported: ["code"],
scopes_supported: ["openid", "offline_access"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
code_challenge_methods_supported: ["S256"],
});
return true;
}
if (url.pathname === "/login" && req.method === "GET") {
const q = url.searchParams;
const redirectUri = q.get("redirect_uri") ?? "";
if (q.get("client_id") !== OAUTH_CLIENT_ID || q.get("response_type") !== "code" || !redirectUri || q.get("code_challenge_method") !== "S256") {
json(res, 400, { error: "invalid_request" });
return true;
}
const code = randomBytes(16).toString("hex");
codes.set(code, { challenge: q.get("code_challenge") ?? "", redirectUri, issuedAt: Date.now() });
const back = new URL(redirectUri);
back.searchParams.set("code", code);
back.searchParams.set("state", q.get("state") ?? "");
res.writeHead(302, { location: back.toString() });
res.end();
return true;
}
if (url.pathname === "/auth/token" && req.method === "POST") {
const form = await readForm(req);
if (form.get("client_id") !== OAUTH_CLIENT_ID || form.get("client_secret") !== OAUTH_CLIENT_SECRET) {
json(res, 400, { error: "invalid_client" });
return true;
}
if (form.get("grant_type") === "authorization_code") {
const code = codes.get(form.get("code") ?? "");
codes.delete(form.get("code") ?? "");
const verifier = form.get("code_verifier") ?? "";
const challenge = createHash("sha256").update(verifier).digest("base64url");
if (!code || code.challenge !== challenge || code.redirectUri !== form.get("redirect_uri") || Date.now() - code.issuedAt > 600_000) {
json(res, 400, { error: "invalid_grant" });
return true;
}
issue(res, null);
return true;
}
if (form.get("grant_type") === "refresh_token") {
const refresh = form.get("refresh_token") ?? "";
const grant = refreshTokens.get(refresh);
if (!grant || grant.password !== account.password) {
json(res, 400, { error: "invalid_grant" });
return true;
}
issue(res, refresh);
return true;
}
json(res, 400, { error: "unsupported_grant_type" });
return true;
}
return false;
}
/** The username the mock signs in, for tests. */
export const OAUTH_USER = USER;
+217
View File
@@ -0,0 +1,217 @@
import { test, before, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
/**
* Signing in on the mail server's own page, end to end against the mock's
* OAuth side: the redirect out, the callback, the session holding tokens
* instead of a password, token renewal, and what ends a session.
*/
const PORT = 18811;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-oauth";
process.env.OAUTH_CLIENT_SECRET = "mock-oauth-secret";
process.env.PUBLIC_URL = "https://webmail.example.test";
const mock = await import("./mock/index.js");
const { oauthMock } = await import("./mock/oauth.js");
const { createApp, pushCredential, sessions } = await import("./app.js");
const { resetOAuthState } = await import("./oauth.js");
const app = createApp();
const CALLBACK = "https://webmail.example.test/api/auth/callback";
/** A cookie jar, since sign-in sets two cookies on different paths. */
let jar = new Map<string, string>();
function keepCookies(res: Response) {
for (const header of res.headers.getSetCookie()) {
const [pair, ...attrs] = header.split(";");
const [name, value] = [pair!.slice(0, pair!.indexOf("=")), pair!.slice(pair!.indexOf("=") + 1)];
const expired = attrs.some((a) => /max-age=0\b/i.test(a.trim()) || /expires=thu, 01 jan 1970/i.test(a.trim()));
if (expired || value === "") jar.delete(name);
else jar.set(name, value);
}
}
async function call(path: string, init: RequestInit = {}) {
const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
const res = await app.request(path, {
...init,
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}), ...(init.headers as Record<string, string>) },
});
keepCookies(res);
return res;
}
async function jsonOf(res: Response) {
const text = await res.text();
return text ? JSON.parse(text) : null;
}
/** Leave for the server's page and come back: returns the callback URL. */
async function goToServerAndBack(username = "[email protected]"): Promise<URL> {
const start = await call(`/api/auth/oauth/start?username=${encodeURIComponent(username)}&remember=1`);
assert.equal(start.status, 302);
const signInPage = new URL(start.headers.get("location")!);
const approved = await fetch(signInPage, { redirect: "manual" });
assert.equal(approved.status, 302, "the mock's page approves the demo user");
return new URL(approved.headers.get("location")!);
}
async function signIn() {
const back = await goToServerAndBack();
const res = await call(`/api/auth/callback${back.search}`);
assert.equal(res.status, 302);
assert.equal(res.headers.get("location"), "/");
assert.ok(jar.get("ihm_session"), "a session cookie was set");
}
beforeEach(() => {
jar = new Map();
oauthMock.reset();
resetOAuthState();
});
before(() => {});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
test("the configuration tells the web app to use the server's page", async () => {
const body = await jsonOf(await call("/api/config"));
assert.equal(body.signIn, "oauth");
assert.equal(body.signInDirect, true, "one mail server: its page asks for the username, not ihasmail");
});
test("with one mail server, sign-in starts without an address", async () => {
const res = await call("/api/auth/oauth/start");
assert.equal(res.status, 302);
const to = new URL(res.headers.get("location")!);
assert.equal(to.searchParams.has("login_hint"), false);
const approved = await fetch(to, { redirect: "manual" });
const back = new URL(approved.headers.get("location")!);
assert.equal((await call(`/api/auth/callback${back.search}`)).headers.get("location"), "/");
assert.equal((await call("/api/auth/session")).status, 200);
});
test("the password form is refused: ihasmail never sees a password", async () => {
const res = await call("/api/auth/login", { method: "POST", body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
assert.equal(res.status, 403);
assert.equal((await jsonOf(res)).error, "oauth_required");
});
test("start sends the browser to the server's page with PKCE and a bound state", async () => {
const res = await call("/api/auth/oauth/[email protected]");
assert.equal(res.status, 302);
const to = new URL(res.headers.get("location")!);
assert.equal(`${to.origin}${to.pathname}`, `http://127.0.0.1:${PORT}/login`);
assert.equal(to.searchParams.get("client_id"), "ihasmail-inbuxa");
assert.equal(to.searchParams.get("redirect_uri"), CALLBACK);
assert.equal(to.searchParams.get("response_type"), "code");
assert.equal(to.searchParams.get("code_challenge_method"), "S256");
assert.match(to.searchParams.get("code_challenge") ?? "", /^[\w-]{43}$/);
assert.equal(to.searchParams.get("login_hint"), "[email protected]");
assert.equal(to.searchParams.get("scope"), "openid offline_access");
assert.equal(jar.get("ihm_session_signin"), to.searchParams.get("state"), "the state is bound to this browser");
});
test("a full sign-in holds tokens, and the session works", async () => {
await signIn();
const res = await call("/api/auth/session");
assert.equal(res.status, 200);
const body = await jsonOf(res);
assert.equal(body.ihasmail.loginName, "[email protected]");
assert.equal(jar.get("ihm_session_signin"), undefined, "the state cookie is cleared");
});
test("the callback comes back cross-site, and is still accepted", async () => {
const back = await goToServerAndBack();
const res = await call(`/api/auth/callback${back.search}`, { headers: { "sec-fetch-site": "cross-site" } });
assert.equal(res.headers.get("location"), "/");
});
test("a callback from a sign-in this browser didn't start is refused", async () => {
const back = await goToServerAndBack();
jar.delete("ihm_session_signin");
const res = await call(`/api/auth/callback${back.search}`);
assert.equal(res.headers.get("location"), "/?signin_error=state_mismatch");
assert.equal(jar.get("ihm_session"), undefined);
});
test("a state is good for one attempt", async () => {
const back = await goToServerAndBack();
const state = jar.get("ihm_session_signin")!;
await call(`/api/auth/callback${back.search}`);
jar = new Map([["ihm_session_signin", state]]);
const again = await call(`/api/auth/callback${back.search}`);
assert.equal(again.headers.get("location"), "/?signin_error=state_mismatch");
});
test("cancelling on the server's page comes back as an error, not a session", async () => {
await call("/api/auth/oauth/[email protected]");
const state = jar.get("ihm_session_signin")!;
const res = await call(`/api/auth/callback?error=access_denied&state=${state}`);
assert.equal(res.headers.get("location"), "/?signin_error=cancelled");
assert.equal(jar.get("ihm_session"), undefined);
});
test("a code the server won't exchange is refused", async () => {
const back = await goToServerAndBack();
back.searchParams.set("code", "not-a-code");
const res = await call(`/api/auth/callback${back.search}`);
assert.equal(res.headers.get("location"), "/?signin_error=exchange_failed");
});
test("an access token about to expire is renewed without the person noticing", async () => {
oauthMock.setAccessTokenTtl(60); // inside the renewal margin from the start
await signIn();
oauthMock.expireAccessTokens(); // the one the session holds is now dead upstream
const res = await call("/api/auth/session?refresh=1");
assert.equal(res.status, 200, "renewed before the call went upstream");
});
test("renewal refused by the server ends the session", async () => {
await signIn();
const cookie = jar.get("ihm_session")!;
const session = sessions.resolve(cookie)!;
// Pretend the token is about to expire, then make the server refuse to renew it.
sessions.updateTokens(cookie, { ...session.tokens!, expiresAt: Date.now() + 1000, refresh: "revoked" });
const res = await call("/api/auth/session");
assert.equal(res.status, 401);
assert.equal(jar.get("ihm_session"), undefined, "and the cookie is cleared");
});
test("a password change signs the session out, since the server revokes its tokens", async () => {
await signIn();
const res = await call("/api/account/password", { method: "POST", body: JSON.stringify({ current: "demo-password", next: "new-password-123" }) });
assert.equal(res.status, 200);
assert.equal((await jsonOf(res)).signedOut, true);
assert.equal((await call("/api/auth/session")).status, 401);
// Put it back for the tests after this one.
const { account } = await import("./mock/config.js");
account.password = "demo-password";
});
test("creating an app password checks the typed password with the server", async () => {
await signIn();
const wrong = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "nope" }) });
assert.equal(wrong.status, 403);
const right = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "demo-password" }) });
assert.equal(right.status, 200);
});
test("push keeps a credential that renews itself", async () => {
oauthMock.setAccessTokenTtl(60);
await signIn();
const session = sessions.resolve(jar.get("ihm_session"))!;
const credential = pushCredential(session);
const first = await credential.get();
oauthMock.expireAccessTokens();
const second = await credential.get();
assert.notEqual(second, first, "a fresh access token");
assert.match(second, /^Bearer mock-at-/);
});
+207
View File
@@ -0,0 +1,207 @@
/**
* Signing in through the mail server's own page (OAuth 2.0 authorization code
* with PKCE), so ihasmail never handles a password to sign someone in.
*
* The flow, with ihasmail as a confidential client registered on the server:
*
* 1. `start()` picks the account's server from the username, reads the
* server's OAuth metadata, and sends the browser to its sign-in page with
* a PKCE challenge and a one-time `state`. The state is bound to the
* browser by a short-lived cookie, so a callback carrying somebody else's
* code can't sign this browser into their account.
* 2. The person signs in there, two-factor included, and the server sends the
* browser back to `/api/auth/callback` with a code.
* 3. `finish()` checks the state, exchanges the code (with the PKCE verifier
* and this client's secret) for an access and a refresh token, and the
* session keeps those, sealed, instead of a password.
*
* Access tokens last an hour; `refreshTokens()` renews them before they run
* out. A password change on the server revokes both tokens, which ends every
* session holding them -- the safe result, and the one the web app is told
* about.
*
* Nothing here is taken from another client's implementation; the shapes are
* RFC 6749, RFC 7636 and RFC 8414.
*/
import { createHash } from "node:crypto";
import { config } from "./config.js";
import { randomToken } from "./crypto.js";
import { UpstreamError, absoluteUpstream } from "./upstream.js";
export interface TokenSet {
access: string;
refresh: string | null;
/** When the access token expires, in ms since the epoch. */
expiresAt: number;
}
interface Metadata {
authorizationEndpoint: string;
tokenEndpoint: string;
scopes: string[];
}
/** Renew an access token this long before it expires. */
export const REFRESH_MARGIN_MS = 5 * 60_000;
/** How long a sign-in may take between leaving and coming back. */
const PENDING_TTL_MS = 10 * 60_000;
const METADATA_TTL_MS = 60 * 60_000;
const MAX_PENDING = 10_000;
export function oauthEnabled(): boolean {
return Boolean(config.oauthClientSecret);
}
/**
* Whether every account is on the same server. Then sign-in needs no address
* first: the server's page asks for the username itself. With several servers
* (MAIL_SERVERS_FILE), the domain picks the server, so the address comes
* first.
*/
export function singleServer(): boolean {
return Object.values(config.stalwartServers).every((url) => url === config.stalwartUrl);
}
/** The one redirect URI registered for this client on the server. */
export function redirectUri(): string {
return `${config.publicUrl}${config.basePath}/api/auth/callback`;
}
const metadataCache = new Map<string, { metadata: Metadata; fetchedAt: number }>();
async function metadataFor(base: string): Promise<Metadata> {
const cached = metadataCache.get(base);
if (cached && Date.now() - cached.fetchedAt < METADATA_TTL_MS) return cached.metadata;
const res = await fetch(`${base}/.well-known/oauth-authorization-server`, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) throw new UpstreamError(`OAuth metadata request failed (${res.status})`, 502);
const doc = (await res.json()) as { authorization_endpoint?: string; token_endpoint?: string; scopes_supported?: string[] };
if (!doc.authorization_endpoint || !doc.token_endpoint) {
throw new UpstreamError("The mail server's OAuth metadata has no authorization or token endpoint", 502);
}
const metadata = {
// Where the *browser* goes, so the server's public address, as advertised.
authorizationEndpoint: new URL(doc.authorization_endpoint, base).toString(),
// Where this process goes, so the configured route, like every other call.
tokenEndpoint: absoluteUpstream(doc.token_endpoint, base),
scopes: doc.scopes_supported ?? [],
};
metadataCache.set(base, { metadata, fetchedAt: Date.now() });
return metadata;
}
interface Pending {
verifier: string;
base: string;
username: string;
remember: boolean;
createdAt: number;
}
const pending = new Map<string, Pending>();
function sweepPending(now = Date.now()) {
for (const [state, p] of pending) if (now - p.createdAt > PENDING_TTL_MS) pending.delete(state);
}
function challengeOf(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
/**
* Begin a sign-in. Returns where to send the browser, and the state to bind
* to it in a cookie.
*/
export async function start(params: { username: string; base: string; remember: boolean }): Promise<{ location: string; state: string }> {
const metadata = await metadataFor(params.base);
sweepPending();
if (pending.size >= MAX_PENDING) throw new UpstreamError("Too many sign-ins in progress", 503);
const state = randomToken(24);
const verifier = randomToken(48);
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, createdAt: Date.now() });
const scope = ["openid", "offline_access"].filter((s) => metadata.scopes.length === 0 || metadata.scopes.includes(s)).join(" ");
const url = new URL(metadata.authorizationEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", config.oauthClientId);
url.searchParams.set("redirect_uri", redirectUri());
if (scope) url.searchParams.set("scope", scope);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challengeOf(verifier));
url.searchParams.set("code_challenge_method", "S256");
if (params.username) url.searchParams.set("login_hint", params.username);
return { location: url.toString(), state };
}
export class SignInError extends Error {
constructor(readonly code: "state_mismatch" | "expired" | "denied" | "exchange_failed", message: string) {
super(message);
}
}
/**
* Finish a sign-in: `state` as it came back in the URL, `boundState` as the
* browser's cookie holds it. Each state is good for one attempt.
*/
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean }> {
const p = pending.get(params.state);
if (!p || !params.boundState || params.boundState !== params.state) {
throw new SignInError("state_mismatch", "This sign-in didn't start in this browser. Try again.");
}
pending.delete(params.state);
if (Date.now() - p.createdAt > PENDING_TTL_MS) throw new SignInError("expired", "The sign-in took too long. Try again.");
const metadata = await metadataFor(p.base);
const tokens = await tokenRequest(metadata.tokenEndpoint, {
grant_type: "authorization_code",
code: params.code,
code_verifier: p.verifier,
redirect_uri: redirectUri(),
});
if (!tokens) throw new SignInError("exchange_failed", "The mail server didn't accept the sign-in. Try again.");
return { tokens, base: p.base, username: p.username, remember: p.remember };
}
/**
* Renew an access token. Null when the server refuses the refresh token --
* revoked by a password change, expired, or the client's secret changed --
* which ends the session. Throws when the server couldn't be asked.
*/
export async function refreshTokens(base: string, tokens: TokenSet): Promise<TokenSet | null> {
if (!tokens.refresh) return null;
const metadata = await metadataFor(base);
const renewed = await tokenRequest(metadata.tokenEndpoint, { grant_type: "refresh_token", refresh_token: tokens.refresh });
// The server hands out a new refresh token only when the old one is close
// to expiring; otherwise the old one stays good.
return renewed && { ...renewed, refresh: renewed.refresh ?? tokens.refresh };
}
async function tokenRequest(endpoint: string, fields: Record<string, string>): Promise<TokenSet | null> {
const res = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
body: new URLSearchParams({ ...fields, client_id: config.oauthClientId, client_secret: config.oauthClientSecret }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 400 || res.status === 401) return null;
if (!res.ok) throw new UpstreamError(`Token request failed (${res.status})`, 502);
const body = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string };
if (!body.access_token || (body.token_type && body.token_type.toLowerCase() !== "bearer")) {
throw new UpstreamError("The mail server returned no usable access token", 502);
}
return {
access: body.access_token,
refresh: body.refresh_token ?? null,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
};
}
export function needsRefresh(tokens: TokenSet, now = Date.now()): boolean {
return tokens.expiresAt - now < REFRESH_MARGIN_MS;
}
/** For tests. */
export function resetOAuthState(): void {
pending.clear();
metadataCache.clear();
}
+64 -9
View File
@@ -1,10 +1,13 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
process.env.PUSH_URL = "https://ihasmail.example";
const push = await import("./push.js");
/** A fixed credential, as a password session hands push. */
const cred = (authorization: string) => ({ get: async () => authorization });
// Nothing in this file may reach the network. Background subscribe() calls
// outlive the test that started them, so the stub stays in place for the
// whole file rather than per test; the per-test stubs below layer on top.
@@ -46,7 +49,7 @@ test("a tab opened before verification gets no fan-out, and a subscription is st
const restore = stubUpstream();
try {
const out = fakeOut();
const entry = push.attach("[email protected]", "a", "Basic x", out as never);
const entry = push.attach("[email protected]", "a", cred("Basic x"), out as never);
assert.equal(entry, null, "not verified yet, so the tab must keep its own relay");
await new Promise((r) => setTimeout(r, 30));
const st = push.pushStatus();
@@ -59,7 +62,7 @@ test("verification then fan-out: one POST reaches every open tab for the account
try {
// First contact starts the subscription; wait for the stubbed create to land.
const first = fakeOut();
push.attach("[email protected]", "a", "Basic y", first as never);
push.attach("[email protected]", "a", cred("Basic y"), first as never);
await new Promise((r) => setTimeout(r, 30));
// Find the token Stalwart would have been given, the way Stalwart learns it: from the subscribe call.
// We cannot read it back through the public API, so verify via the status transition instead:
@@ -75,7 +78,7 @@ test("a StateChange is written to attached tabs as an SSE frame, and closed tabs
const restore = stubUpstream();
try {
const out1 = fakeOut(), out2 = fakeOut();
push.attach("[email protected]", "a", "Basic z", out1 as never);
push.attach("[email protected]", "a", cred("Basic z"), out1 as never);
await new Promise((r) => setTimeout(r, 30));
// Verify by handing the module its own token: pushStatus does not expose it, so read it from the
// subscribe request the stub saw. Simplest faithful route: capture the URL Stalwart would POST to.
@@ -88,14 +91,14 @@ test("a StateChange is written to attached tabs as an SSE frame, and closed tabs
return real(input, init);
}) as typeof fetch;
// Force a renewal-style subscribe so the URL passes through the capturing fetch.
push.attach("[email protected]", "a", "Basic w", out1 as never);
push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
await new Promise((r) => setTimeout(r, 30));
globalThis.fetch = real;
assert.ok(token, "the subscribe call carries the push URL with the token");
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
const entry = push.attach("[email protected]", "a", "Basic w", out1 as never);
const entry = push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
assert.ok(entry, "verified: the tab is served by fan-out");
push.attach("[email protected]", "a", "Basic w", out2 as never);
push.attach("[email protected]", "a", cred("Basic w"), out2 as never);
assert.equal(await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s1" } } }), 200);
assert.match(out1.written.at(-1) ?? "", /^event: state\ndata: \{"@type":"StateChange"/);
assert.equal(out2.written.length, 1);
@@ -119,12 +122,12 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
if (m) token = m[1];
return real(input, init);
}) as typeof fetch;
push.prepare("[email protected]", "a", "Basic m"); // sign-in starts the subscription
push.prepare("[email protected]", "a", cred("Basic m")); // sign-in starts the subscription
await new Promise((r) => setTimeout(r, 30));
globalThis.fetch = real;
assert.ok(token);
const out = fakeOut(); let dropped = 0;
assert.equal(push.attach("[email protected]", "a", "Basic m", out as never), null, "not yet verified: relay");
assert.equal(push.attach("[email protected]", "a", cred("Basic m"), out as never), null, "not yet verified: relay");
push.attachRelay("[email protected]", out as never, () => { dropped++; });
assert.equal(push.pushStatus().tabs.relay >= 1, true);
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
@@ -133,3 +136,55 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
} finally { restore(); }
});
test("a new subscription clears what this installation left behind, and only that", async () => {
// What a restart finds: its own subscription from the last process, another
// installation's on the same server, a browser's, and the old id format.
const calls: Array<[string, Record<string, unknown>]> = [];
let ownPrefix = "";
const real = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/.well-known/jmap") || url.includes("/jmap/session")) {
return new Response(JSON.stringify({ apiUrl: "http://127.0.0.1:1/jmap/", primaryAccounts: { "urn:ietf:params:jmap:mail": "a" },
accounts: { a: {} }, capabilities: {}, eventSourceUrl: "", downloadUrl: "", uploadUrl: "", state: "s" }), { status: 200, headers: { "content-type": "application/json" } });
}
const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: [string, Record<string, unknown>, string][] };
const [name, args, id] = methodCalls[0]!;
calls.push([name, args]);
let result: Record<string, unknown> = {};
if (name === "PushSubscription/get") {
result = { list: [
{ id: "mine-before", deviceClientId: `${ownPrefix}oldtoken` },
{ id: "other-install", deviceClientId: "ihasmail-proxy-ZZZZZZZZZZ-12345678" },
{ id: "a-browser", deviceClientId: "ihasmail-00000000-0000-4000-8000-000000000001" },
{ id: "old-format", deviceClientId: "ihasmail-Ab3_x9Qz" },
] };
} else if (name === "PushSubscription/set" && args.create) {
const body = (args.create as Record<string, { deviceClientId: string }>).s!;
result = { created: { s: { id: "fresh", expires: new Date(Date.now() + 7 * 86_400_000).toISOString() } } };
calls.at(-1)![1] = { ...args, deviceClientId: body.deviceClientId };
} else {
result = { destroyed: args.destroy };
}
return new Response(JSON.stringify({ methodResponses: [[name, result, id]] }), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
try {
// The installation's prefix, learned the way the server makes it: from its first create.
push.prepare("[email protected]", "a", cred("Basic p"));
await new Promise((r) => setTimeout(r, 30));
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
assert.match(deviceId, /^ihasmail-proxy-[A-Za-z0-9_-]{10}-[A-Za-z0-9_-]{8}$/, "the server's own prefix, naming the installation");
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
calls.length = 0;
push.prepare("[email protected]", "a", cred("Basic r"));
await new Promise((r) => setTimeout(r, 30));
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
assert.ok(calls.some(([n, a]) => n === "PushSubscription/set" && a.create), "and a new one is made");
} finally {
globalThis.fetch = real;
}
});
+75 -12
View File
@@ -23,7 +23,7 @@
* transition loses no events, because a tab opened before verification keeps
* its own relay for its whole life.
*/
import { randomBytes } from "node:crypto";
import { createHash, randomBytes } from "node:crypto";
import type { ServerResponse } from "node:http";
import { config } from "./config.js";
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
@@ -39,7 +39,7 @@ interface AccountPush {
accountId: string;
base: string;
token: string; // what Stalwart puts in the URL
authorization: string; // one live session's credential, for set/verify/renew
credential: PushCredential; // one live session's credential, for set/verify/renew
subscriptionId: string | null;
state: "pending" | "verified" | "failed";
since: number;
@@ -49,6 +49,15 @@ interface AccountPush {
relays: Map<ServerResponse, () => void>;
}
/**
* How push authenticates its own calls. A password session's is fixed; an
* OAuth session's renews its access token itself, since a subscription lives
* for days and an access token for an hour. See pushCredential() in app.ts.
*/
export interface PushCredential {
get(): Promise<string>;
}
const byKey = new Map<string, AccountPush>();
const byToken = new Map<string, AccountPush>();
let sweeper: NodeJS.Timeout | null = null;
@@ -60,10 +69,11 @@ export function pushEnabled(): boolean {
function keyFor(base: string, username: string) { return `${base} ${username}`; }
async function jmap(entry: AccountPush, calls: unknown[]) {
const upstream = await getUpstreamSession(entry.key, entry.authorization, entry.base);
const authorization = await entry.credential.get();
const upstream = await getUpstreamSession(entry.key, authorization, entry.base);
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
method: "POST",
headers: { authorization: entry.authorization, "content-type": "application/json", accept: "application/json" },
headers: { authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ using: USING, methodCalls: calls }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
@@ -71,10 +81,58 @@ async function jmap(entry: AccountPush, calls: unknown[]) {
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
}
/*
* Whose subscriptions are whose.
*
* Each process used to register a subscription per account and forget it when
* it stopped -- state here is in memory, and an immutable deployment restarts
* on every deploy -- so each restart left one more behind, receiving 404s until
* it expired. Stalwart keeps them all and allows fifteen per account (checked
* live on 0.16.22, 2026-09-16), which the browser subscriptions count against
* too (#375).
*
* So the device id names the installation -- a hash of the address Stalwart
* posts to, stable across restarts and different for another installation on
* the same server -- and a new subscription first removes the ones this
* installation left before. The `ihasmail-proxy-` prefix keeps them apart from
* the browsers' own, which the web client may clear to make room.
*/
function installationId(): string {
return createHash("sha256").update(`${config.pushUrl}${config.basePath}`).digest("base64url").slice(0, 10);
}
function deviceIdFor(entry: AccountPush): string {
return `ihasmail-proxy-${installationId()}-${entry.token.slice(0, 8)}`;
}
async function removeLeftovers(entry: AccountPush) {
const mine = `ihasmail-proxy-${installationId()}-`;
const r = await jmap(entry, [["PushSubscription/get", { ids: null, properties: ["id", "deviceClientId"] }, "0"]]);
const list = (r.methodResponses[0]?.[1] as { list?: Array<{ id: string; deviceClientId?: string }> }).list ?? [];
const stale = list.filter((s) => s.id !== entry.subscriptionId && String(s.deviceClientId ?? "").startsWith(mine)).map((s) => s.id);
if (stale.length) await jmap(entry, [["PushSubscription/set", { destroy: stale }, "0"]]);
}
/** Give the live subscription another week, rather than registering a second one. */
async function renew(entry: AccountPush) {
const expires = new Date(Date.now() + 7 * 86_400_000).toISOString().replace(/\.\d+Z$/, "Z");
const r = await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { expires } } }, "0"]]);
const res = r.methodResponses[0]?.[1] as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> };
if (!res.updated || !(entry.subscriptionId! in res.updated)) throw new Error("subscription not extended");
const got = await jmap(entry, [["PushSubscription/get", { ids: [entry.subscriptionId], properties: ["expires"] }, "0"]]);
const after = (got.methodResponses[0]?.[1] as { list?: Array<{ expires?: string | null }> }).list?.[0]?.expires;
entry.expires = after ? Date.parse(after) : Date.parse(expires);
}
async function subscribe(entry: AccountPush) {
try {
await removeLeftovers(entry);
} catch (err) {
console.warn(`[ihasmail] push: could not clear old subscriptions for ${entry.username}: ${(err as Error).message}`);
}
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
const r = await jmap(entry, [["PushSubscription/set", {
create: { s: { deviceClientId: `ihasmail-${entry.token.slice(0, 8)}`, url,
create: { s: { deviceClientId: deviceIdFor(entry), url,
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
}, "0"]]);
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
@@ -111,14 +169,14 @@ async function unsubscribe(entry: AccountPush) {
* by the time the browser opens its stream the verification is usually
* already in flight, and called again by attach() as a safety net.
*/
export function prepare(username: string, accountId: string, authorization: string): AccountPush | null {
export function prepare(username: string, accountId: string, credential: PushCredential): AccountPush | null {
if (!pushEnabled()) return null;
const base = upstreamFor(username);
const key = keyFor(base, username);
let entry = byKey.get(key);
if (!entry) {
entry = { key, username, accountId, base, token: randomBytes(32).toString("base64url"),
authorization, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
credential, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
byKey.set(key, entry); byToken.set(entry.token, entry);
subscribe(entry).catch((err) => {
entry!.state = "failed";
@@ -126,7 +184,7 @@ export function prepare(username: string, accountId: string, authorization: stri
});
startSweeper();
} else {
entry.authorization = authorization; // keep a live credential for renewals
entry.credential = credential; // keep a live credential for renewals
}
return entry;
}
@@ -135,8 +193,8 @@ export function prepare(username: string, accountId: string, authorization: stri
* Called when a tab opens. Returns the account's push entry if the tab can
* be served by fan-out right now, or null if it must hold its own relay.
*/
export function attach(username: string, accountId: string, authorization: string, out: ServerResponse): AccountPush | null {
const entry = prepare(username, accountId, authorization);
export function attach(username: string, accountId: string, credential: PushCredential, out: ServerResponse): AccountPush | null {
const entry = prepare(username, accountId, credential);
if (!entry || entry.state !== "verified") return null;
entry.tabs.add(out);
out.on("close", () => { entry.tabs.delete(out); });
@@ -184,8 +242,13 @@ function startSweeper() {
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
}
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
entry.state = "pending"; entry.since = now;
subscribe(entry).catch(() => { entry.state = "failed"; });
// Extended in place, which keeps it verified. Only if the server will
// not is a new one registered, and that one has to verify again.
entry.expires = now + RENEW_BEFORE_MS;
renew(entry).catch(() => {
entry.state = "pending"; entry.since = Date.now();
subscribe(entry).catch(() => { entry.state = "failed"; });
});
}
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
void unsubscribe(entry);
+115
View File
@@ -0,0 +1,115 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
/**
* How much a request may make the proxy hold in memory.
*
* Routes that read JSON take a small body and no more, whether or not anyone
* is signed in. The JMAP route streams straight through for a session that may
* administer; for one that may not, it reads the body to check it, and that
* read is capped in size, in how many one session runs at once, and in bytes
* across everyone.
*/
const PORT = 18813;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-request-limits";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const { rateLimitKey } = await import("./clientip.js");
const app = createApp();
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
let cookie = "";
/** A body that arrives in chunks with no content-length, as a chunked upload does. */
function chunked(size: number, chunk = 256 * 1024): ReadableStream<Uint8Array> {
let sent = 0;
return new ReadableStream({
pull(controller) {
if (sent >= size) return controller.close();
const n = Math.min(chunk, size - sent);
controller.enqueue(new Uint8Array(n).fill(0x20));
sent += n;
},
});
}
const jmap = (body: BodyInit) =>
app.request("/api/jmap", { method: "POST", headers: { ...HEADERS, cookie }, body, duplex: "half" } as RequestInit);
before(async () => {
// Not remembered: a device that is not the person's own, so JMAP is checked.
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
assert.equal(res.status, 200, "login should succeed against the mock");
cookie = res.headers.get("set-cookie")!.split(";")[0]!;
});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
test("sign-in refuses a large body by its length, before reading it", async () => {
const res = await app.request("/api/auth/login", {
method: "POST",
headers: { ...HEADERS, "content-length": String(200 * 1024 * 1024) },
body: "{}",
});
assert.equal(res.status, 413);
});
test("sign-in refuses a large chunked body without holding all of it", async () => {
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: chunked(2 * 1024 * 1024), duplex: "half" } as RequestInit);
assert.equal(res.status, 413);
});
test("other JSON routes are limited too", async () => {
const res = await app.request("/api/account/password", { method: "POST", headers: { ...HEADERS, cookie }, body: chunked(1024 * 1024), duplex: "half" } as RequestInit);
assert.equal(res.status, 413);
});
test("an ordinary checked JMAP request still goes through", async () => {
const res = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls: [["Mailbox/get", { accountId: "a1", ids: [] }, "0"]] }));
assert.equal(res.status, 200);
});
test("a JMAP request larger than the check allows is refused", async () => {
assert.equal((await jmap(chunked(5 * 1024 * 1024))).status, 413);
});
test("a JMAP request larger than a sign-in body is not caught by the small-body limit", async () => {
// 200 KB of whitespace around a real request: valid JSON, well past 64 KB.
const body = `${" ".repeat(200 * 1024)}{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Core/echo",{},"0"]]}`;
assert.equal((await jmap(body)).status, 200);
});
test("one session cannot hold more than a few checked reads at once", async () => {
// Bodies that never finish: each holds its slot until its stream fails.
const controllers: ReadableStreamDefaultController<Uint8Array>[] = [];
const pending: Promise<Response>[] = [];
for (let i = 0; i < 4; i++) {
const s = new ReadableStream<Uint8Array>({ start(c) { controllers.push(c); c.enqueue(new TextEncoder().encode("{")); } });
pending.push(jmap(s));
}
await new Promise((r) => setTimeout(r, 50));
const fifth = await jmap("{}");
assert.equal(fifth.status, 429);
assert.ok(fifth.headers.get("retry-after"));
for (const c of controllers) c.error(new Error("client went away"));
await Promise.allSettled(pending);
// The slots are given back once those requests end.
const again = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["Core/echo", {}, "0"]] }));
assert.equal(again.status, 200);
});
test("IPv6 addresses share a rate-limit key across their /64", () => {
assert.equal(rateLimitKey("2001:db8:1:2:aaaa::1"), rateLimitKey("2001:db8:1:2:ffff:ffff:ffff:ffff"));
assert.notEqual(rateLimitKey("2001:db8:1:2::1"), rateLimitKey("2001:db8:1:3::1"));
assert.equal(rateLimitKey("2001:db8:1:2::1"), "2001:db8:1:2::/64");
assert.equal(rateLimitKey("198.51.100.7"), "198.51.100.7");
assert.equal(rateLimitKey("unknown"), "unknown");
});
+15 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { SessionStore } from "./sessions.js";
import { SessionStore, accountKey } from "./sessions.js";
import { normalizeLocale } from "./upstream.js";
import { deriveKey, open, seal, sha256 } from "./crypto.js";
import { RateLimiter } from "./ratelimit.js";
@@ -30,6 +30,20 @@ test("session store creates, resolves, and refuses tampered cookies", () => {
assert.equal(store.resolve(cookie), null);
});
test("sessions group by the account, however its name was typed", () => {
const store = new SessionStore("");
const key = accountKey("https://mail.example.com", "[email protected]");
const a = store.create({ username: "alice", account: key, password: "pw", remember: false, userAgent: "", ip: "" });
const b = store.create({ username: "[email protected]", account: accountKey("https://mail.example.com", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
// The same name on another configured server is another account.
store.create({ username: "[email protected]", account: accountKey("https://other.example.net", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
assert.equal(a.session.account, b.session.account);
assert.equal(store.listForUser(a.session.account).length, 2);
assert.equal(store.destroyAllForUser(a.session.account, a.session.id), 1);
assert.equal(store.resolve(b.cookie), null, "the other spelling was signed out");
assert.ok(store.resolve(a.cookie), "this session was kept");
});
test("persisted session data does not contain the password", () => {
const store = new SessionStore("");
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
+72 -18
View File
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
import { randomBytes } from "node:crypto";
import { config } from "./config.js";
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
import type { TokenSet } from "./oauth.js";
export interface StoredSession {
id: string;
@@ -10,9 +11,11 @@ export interface StoredSession {
secretHash: string;
/** base64 random salt for key derivation */
salt: string;
/** sealed JSON {username, password} */
/** sealed JSON: `{u, p}` for a password, `{u, t}` for OAuth tokens (see oauth.ts) */
sealedCredentials: string;
username: string;
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
account?: string;
createdAt: number;
lastSeenAt: number;
expiresAt: number;
@@ -24,8 +27,12 @@ export interface StoredSession {
export interface LiveSession {
id: string;
username: string;
/** Basic Authorization header value for upstream calls. */
/** See `accountKey`. */
account: string;
/** Authorization header value for upstream calls: Basic, or Bearer for OAuth. */
authorization: string;
/** The OAuth tokens behind `authorization`, or null for a password session. */
tokens: TokenSet | null;
remember: boolean;
createdAt: number;
lastSeenAt: number;
@@ -46,9 +53,30 @@ export interface SessionSummary {
ip: string;
}
/**
* The key sessions are grouped by for "sign out everywhere else".
*
* Not the username as typed: Stalwart takes `[email protected]` and a bare
* `alice` as the same account, and a session opened either way was missing
* from the list and survived the sign-out. The server's own name for the
* account, lower-cased, and the server it lives on -- the same name on two
* configured servers is two accounts.
*/
export function accountKey(upstream: string, canonicalUsername: string): string {
return `${upstream}|${canonicalUsername.trim().toLowerCase()}`;
}
function accountOf(s: StoredSession): string {
return s.account ?? s.username.trim().toLowerCase();
}
export interface CreateSessionParams {
username: string;
password: string;
/** From `accountKey`; defaults to the lower-cased username. */
account?: string;
/** Exactly one of `password` and `tokens`. */
password?: string;
tokens?: TokenSet;
remember: boolean;
userAgent: string;
ip: string;
@@ -84,13 +112,25 @@ export interface SessionBackend {
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
resolve(cookie: string | undefined): LiveSession | null;
reseal(cookie: string | undefined, password: string): boolean;
/** Store renewed OAuth tokens in place of the ones the session holds. */
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean;
destroy(id: string): void;
destroyAllForUser(username: string, exceptId?: string): number;
listForUser(username: string): SessionSummary[];
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
destroyAllForUser(account: string, exceptId?: string): number;
listForUser(account: string): SessionSummary[];
}
const COOKIE_SEP = ".";
/** What a session seals: a password, or OAuth tokens. */
type Sealed = { u: string; p: string } | { u: string; t: TokenSet };
function sealable(username: string, params: { password?: string; tokens?: TokenSet }): Sealed {
if (params.tokens) return { u: username, t: params.tokens };
if (params.password !== undefined) return { u: username, p: params.password };
throw new Error("a session needs a password or tokens");
}
export class SessionStore implements SessionBackend {
private sessions = new Map<string, StoredSession>();
private dirty = false;
@@ -170,8 +210,9 @@ export class SessionStore implements SessionBackend {
id,
secretHash: sha256(secret),
salt: salt.toString("base64"),
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
sealedCredentials: seal(JSON.stringify(sealable(params.username, params)), key),
username: params.username,
account: params.account ?? params.username.trim().toLowerCase(),
createdAt: now,
lastSeenAt: now,
expiresAt: now + ttl,
@@ -182,7 +223,7 @@ export class SessionStore implements SessionBackend {
this.sessions.set(id, stored);
this.scheduleSave();
const cookie = `${id}${COOKIE_SEP}${secret}`;
return { cookie, session: this.toLive(stored, params.username, params.password) };
return { cookie, session: this.toLive(stored, sealable(params.username, params)) };
}
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
@@ -204,9 +245,9 @@ export class SessionStore implements SessionBackend {
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
const json = open(stored.sealedCredentials, key);
if (!json) return null;
let creds: { u: string; p: string };
let creds: Sealed;
try {
creds = JSON.parse(json) as { u: string; p: string };
creds = JSON.parse(json) as Sealed;
} catch {
return null;
}
@@ -217,7 +258,7 @@ export class SessionStore implements SessionBackend {
stored.expiresAt = now + ttl;
this.scheduleSave();
}
return this.toLive(stored, creds.u, creds.p);
return this.toLive(stored, creds);
}
/**
@@ -230,6 +271,14 @@ export class SessionStore implements SessionBackend {
* secret half of it, which the server never keeps.
*/
reseal(cookie: string | undefined, password: string): boolean {
return this.rewrite(cookie, (username) => ({ u: username, p: password }));
}
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean {
return this.rewrite(cookie, (username) => ({ u: username, t: tokens }));
}
private rewrite(cookie: string | undefined, next: (username: string) => Sealed): boolean {
if (!cookie) return false;
const idx = cookie.indexOf(COOKIE_SEP);
if (idx <= 0) return false;
@@ -239,7 +288,7 @@ export class SessionStore implements SessionBackend {
if (!stored) return false;
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
stored.sealedCredentials = seal(JSON.stringify(next(stored.username)), key);
this.scheduleSave();
return true;
}
@@ -248,10 +297,10 @@ export class SessionStore implements SessionBackend {
if (this.sessions.delete(id)) this.scheduleSave();
}
destroyAllForUser(username: string, exceptId?: string): number {
destroyAllForUser(account: string, exceptId?: string): number {
let n = 0;
for (const [id, s] of this.sessions) {
if (s.username === username && id !== exceptId) {
if (accountOf(s) === account && id !== exceptId) {
this.sessions.delete(id);
n++;
}
@@ -260,21 +309,26 @@ export class SessionStore implements SessionBackend {
return n;
}
listForUser(username: string): SessionSummary[] {
listForUser(account: string): SessionSummary[] {
const out = [];
for (const s of this.sessions.values()) {
if (s.username !== username) continue;
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
if (accountOf(s) !== account) continue;
const { secretHash: _h, salt: _s, sealedCredentials: _c, account: _a, ...rest } = s;
out.push(rest);
}
return out;
}
private toLive(s: StoredSession, username: string, password: string): LiveSession {
private toLive(s: StoredSession, creds: Sealed): LiveSession {
const username = creds.u;
return {
id: s.id,
username,
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
account: accountOf(s),
authorization: "t" in creds
? `Bearer ${creds.t.access}`
: `Basic ${Buffer.from(`${username}:${creds.p}`, "utf8").toString("base64")}`,
tokens: "t" in creds ? creds.t : null,
remember: s.remember,
createdAt: s.createdAt,
lastSeenAt: s.lastSeenAt,
+1 -1
View File
@@ -59,7 +59,7 @@ test("the example's commentary cannot be mistaken for a section", () => {
* reason: an example that no longer loads is worse than no example, because
* the first experience of the feature is a server that refuses to start.
*/
const SERVERS = fileURLToPath(new URL("../../stalwart-servers.example.json", import.meta.url));
const SERVERS = fileURLToPath(new URL("../../mail-servers.example.json", import.meta.url));
test("the example server mapping is valid JSON", () => {
assert.doesNotThrow(() => JSON.parse(readFileSync(SERVERS, "utf8")));
+79
View File
@@ -0,0 +1,79 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "node:zlib";
/*
* The bundle goes out compressed once, at build time, and anything revalidated
* can be answered with a 304.
*
* Before this the server gzipped the bundle again for every request that asked,
* never offered Brotli, and sent no validator for the shell -- so each reload's
* revalidation of index.html and sw.js downloaded them in full.
*/
const root = mkdtempSync(join(tmpdir(), "ihasmail-precompressed-"));
mkdirSync(join(root, "assets"));
const js = `console.log(${JSON.stringify("x".repeat(4000))});\n`;
writeFileSync(join(root, "assets", "app-a1b2c3.js"), js);
writeFileSync(join(root, "assets", "app-a1b2c3.js.br"), brotliCompressSync(js));
writeFileSync(join(root, "assets", "app-a1b2c3.js.gz"), gzipSync(js));
writeFileSync(join(root, "assets", "plain-d4e5f6.js"), js);
// A copy left over from an older build of the same name must not be served.
writeFileSync(join(root, "assets", "stale-000000.js"), js);
writeFileSync(join(root, "assets", "stale-000000.js.br"), brotliCompressSync("old"));
const old = new Date(Date.now() - 60_000);
utimesSync(join(root, "assets", "stale-000000.js.br"), old, old);
writeFileSync(join(root, "sw.js"), "/* worker */\n");
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
process.env.STATIC_DIR = root;
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
const app = createApp();
const get = (path: string, headers: Record<string, string> = {}) => app.request(path, { headers });
test("Brotli is served where the browser takes it", async () => {
const res = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, deflate, br" });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), "br");
assert.equal(res.headers.get("vary"), "Accept-Encoding");
assert.equal(res.headers.get("content-type"), "text/javascript; charset=utf-8");
assert.equal(brotliDecompressSync(Buffer.from(await res.arrayBuffer())).toString(), js);
});
test("gzip where Brotli is not accepted, and nothing where neither is", async () => {
const gz = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, br;q=0" });
assert.equal(gz.headers.get("content-encoding"), "gzip");
assert.equal(gunzipSync(Buffer.from(await gz.arrayBuffer())).toString(), js);
const plain = await get("/assets/app-a1b2c3.js");
assert.equal(plain.headers.get("content-encoding"), null);
assert.equal(await plain.text(), js);
});
test("a file without a copy is compressed as before", async () => {
const res = await get("/assets/plain-d4e5f6.js", { "accept-encoding": "gzip" });
assert.equal(res.headers.get("content-encoding"), "gzip");
assert.equal(gunzipSync(Buffer.from(await res.arrayBuffer())).toString(), js);
});
test("a copy older than its file is ignored", async () => {
const res = await get("/assets/stale-000000.js", { "accept-encoding": "br" });
assert.notEqual(res.headers.get("content-encoding"), "br");
});
test("the shell and the worker answer a revalidation with 304", async () => {
for (const path of ["/", "/sw.js"]) {
const first = await get(path);
const etag = first.headers.get("etag");
assert.ok(etag, `${path} carries a validator`);
await first.arrayBuffer();
const again = await get(path, { "if-none-match": etag! });
assert.equal(again.status, 304, `${path} is not sent again`);
assert.equal(await again.text(), "");
const changed = await get(path, { "if-none-match": `"something-else"` });
assert.equal(changed.status, 200);
}
});
+76 -4
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { stat, readFile } from "node:fs/promises";
import { extname, join, normalize, resolve, sep } from "node:path";
@@ -77,9 +78,61 @@ export const APP_CSP = [
"manifest-src 'self'",
].join("; ");
/*
* What a file is, for the purpose of "has it changed". The shell and the
* never-stale files are revalidated on every load; with no validator to send
* back, every revalidation downloaded the whole file again.
*/
function etagOf(size: number, mtimeMs: number): string {
return `W/"${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}"`;
}
function notModified(c: Context, etag: string): boolean {
const sent = c.req.header("if-none-match");
return Boolean(sent && sent.split(",").some((t) => t.trim() === etag || t.trim() === "*"));
}
/*
* The encodings a build can carry beside a file, best first. See
* scripts/precompress.mjs, which writes them.
*/
const PRECOMPRESSED: Array<{ token: string; suffix: string; encoding: string }> = [
{ token: "br", suffix: ".br", encoding: "br" },
{ token: "gzip", suffix: ".gz", encoding: "gzip" },
];
function accepts(c: Context, token: string): boolean {
const header = c.req.header("accept-encoding") ?? "";
return header.split(",").some((part) => {
const [name, ...params] = part.trim().split(";");
if (name?.trim().toLowerCase() !== token) return false;
const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
return !q || Number(q.slice(2)) > 0;
});
}
export function staticHandler(root: string, basePath = ""): Handler {
const absRoot = resolve(root);
let indexCache: { body: string; mtime: number } | null = null;
let indexCache: { body: string; mtime: number; etag: string } | null = null;
/** Which precompressed copies exist, per file and modification time. */
const variants = new Map<string, { mtime: number; found: Map<string, number> }>();
async function variantsOf(filePath: string, mtime: number): Promise<Map<string, number>> {
const known = variants.get(filePath);
if (known && known.mtime === mtime) return known.found;
const found = new Map<string, number>();
for (const v of PRECOMPRESSED) {
try {
const st = await stat(filePath + v.suffix);
// A copy older than the file it came from describes something else.
if (st.isFile() && st.mtimeMs >= mtime) found.set(v.suffix, st.size);
} catch {
/* none */
}
}
variants.set(filePath, { mtime, found });
return found;
}
let mismatchWarned = false;
/**
@@ -107,13 +160,16 @@ export function staticHandler(root: string, basePath = ""): Handler {
const p = join(absRoot, "index.html");
const st = await stat(p);
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
const body = await readFile(p, "utf8");
indexCache = { body, mtime: st.mtimeMs, etag: `"${createHash("sha256").update(body).digest("base64url").slice(0, 22)}"` };
mismatchWarned = false;
}
warnOnBaseMismatch(indexCache.body);
c.header("Content-Type", "text/html; charset=utf-8");
c.header("Cache-Control", "no-cache");
c.header("Content-Security-Policy", APP_CSP);
c.header("ETag", indexCache.etag);
if (notModified(c, indexCache.etag)) return c.body(null, 304);
return c.body(indexCache.body);
} catch {
c.header("Content-Type", "text/plain; charset=utf-8");
@@ -142,7 +198,8 @@ export function staticHandler(root: string, basePath = ""): Handler {
if (!st.isFile()) return serveIndex(c);
const ext = extname(filePath).toLowerCase();
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
c.header("Content-Length", String(st.size));
const etag = etagOf(st.size, st.mtimeMs);
c.header("ETag", etag);
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
c.header("Cache-Control", "public, max-age=31536000, immutable");
} else if (ext === ".html" || isNeverStale(rel, ext)) {
@@ -151,8 +208,23 @@ export function staticHandler(root: string, basePath = ""): Handler {
} else {
c.header("Cache-Control", "public, max-age=3600");
}
if (notModified(c, etag)) return c.body(null, 304);
// Serve a copy made at build time where the browser takes one.
let servePath = filePath;
let size = st.size;
const found = await variantsOf(filePath, st.mtimeMs);
if (found.size) {
c.header("Vary", "Accept-Encoding");
const pick = PRECOMPRESSED.find((v) => found.has(v.suffix) && accepts(c, v.token));
if (pick) {
servePath = filePath + pick.suffix;
size = found.get(pick.suffix)!;
c.header("Content-Encoding", pick.encoding);
}
}
c.header("Content-Length", String(size));
if (c.req.method === "HEAD") return c.body(null);
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
const stream = Readable.toWeb(createReadStream(servePath)) as ReadableStream;
return c.body(stream);
} catch {
// SPA fallback for client-side routes (no file extension) only.
+1 -1
View File
@@ -26,7 +26,7 @@ writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
writeFileSync(join(root, "img.png"), "not really a png");
process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
const cacheControl = async (path: string) => {
+23 -6
View File
@@ -37,7 +37,7 @@ const SESSION_CACHE_MS = 5 * 60_000;
/**
* The Stalwart a username belongs to.
*
* `STALWART_URL` is the default and is always the answer for a domain nobody
* `MAIL_SERVER_URL` is the default and is always the answer for a domain nobody
* mapped -- and for a bare username, which Stalwart accepts and which has no
* domain to map (#238).
*
@@ -59,7 +59,7 @@ export function upstreamFor(username: string): string {
* Where the administrator signed in as `username` opens Stalwart's own
* administration.
*
* What the operator configured wins -- STALWART_ADMIN_URL for the default
* What the operator configured wins -- ADMIN_URL for the default
* server, a servers file entry's `adminUrl` for a routed domain -- and what was
* found on the account's own server (`detected`) is used otherwise. Routing is
* the same as `upstreamFor`: a routed domain is never pointed at the default
@@ -93,7 +93,7 @@ export function adminPrefixFrom(responses: [string, Record<string, unknown>, str
/**
* The public origin a Stalwart session belongs to: the host it advertises in
* its own URLs, which is the address people reach it at even when this server
* talks to it on a private one (STALWART_URL=http://127.0.0.1:…). A relative
* talks to it on a private one (MAIL_SERVER_URL=http://127.0.0.1:…). A relative
* URL falls back to the configured base.
*/
export function advertisedOrigin(session: Pick<UpstreamSession, "apiUrl" | "baseUrl">): string | null {
@@ -233,6 +233,23 @@ export interface AccountInfo {
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
const INFO_CACHE_MS = 30 * 60_000;
/*
* Both caches are keyed by session, and used to lose an entry only when that
* session signed out or was refused -- not when it simply expired, which is how
* most sessions end. An entry past its age is never used again, so dropping
* those on a timer is all it takes to stop them accumulating.
*/
export function sweepUpstreamCaches(now = Date.now()): void {
for (const [id, v] of sessionCache) if (now - v.fetchedAt >= SESSION_CACHE_MS) sessionCache.delete(id);
for (const [id, v] of infoCache) if (now - v.fetchedAt >= INFO_CACHE_MS) infoCache.delete(id);
}
setInterval(() => sweepUpstreamCaches(), SESSION_CACHE_MS).unref();
/** How many sessions the caches hold; for tests. */
export function upstreamCacheSizes(): { sessions: number; info: number } {
return { sessions: sessionCache.size, info: infoCache.size };
}
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
/**
@@ -410,7 +427,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
};
}
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
/** Resolve a possibly-relative upstream URL template against MAIL_SERVER_URL. */
/**
* Resolve a URL Stalwart handed us against the server we were configured to
* talk to.
@@ -418,7 +435,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
* Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl
* and the rest -- built from its public hostname, which is always https. A
* proxy that follows them takes every upstream call, and every held push
* stream, out through the public route even when STALWART_URL names a private
* stream, out through the public route even when MAIL_SERVER_URL names a private
* plain-HTTP hop on the same network. Measured, that TLS leg is ~80 KiB of
* native OpenSSL state per signed-in tab: 60% of what a tab costs, and the
* whole difference between 1,665 and 3,680 tabs in 256 MiB.
@@ -426,7 +443,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
* So by default only the path and query are taken from the advertised URL;
* scheme, host and port come from the configured base. That is what a proxy
* should have done all along -- the operator named the route on purpose.
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behavior for a setup
* MAIL_SERVER_FOLLOW_ADVERTISED_URLS=1 restores the old behavior for a setup
* that genuinely needs to reach Stalwart at a different origin than the one
* it was given.
*/
+1 -1
View File
@@ -23,7 +23,7 @@
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>ihasmail</title>
<title>INBUXA</title>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"build": "tsc -p tsconfig.json --noEmit && vite build && node ../scripts/precompress.mjs dist",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "ihasmail",
"short_name": "ihasmail",
"description": "Fast, friendly JMAP webmail for Stalwart",
"name": "INBUXA",
"short_name": "INBUXA",
"description": "INBUXA webmail",
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
"_comment_id": "There is deliberately no `id`. It is the one member NOT resolved against this file's address -- the spec resolves it against the origin of start_url, so `./`, `mail` and `/mail` all mean the same thing at the domain root and none of them can name a subpath mount. Adding one would therefore break the same thing the note above describes. Worse, the default id IS start_url, which is already mount-correct: writing an id now would give every installed copy a new identity and orphan it as a second app rather than updating it. If one is ever wanted it has to be substituted at build time from BASE_PATH, and the changeover costs everybody their install.",
"start_url": "mail",
+205 -12
View File
@@ -18,18 +18,169 @@ const VERSION = "ihasmail-v2";
* eventually would.
*/
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
/*
* Only the app page may be kept as the app page.
*
* The mount's root is not always the app: demo.ihasmail.com puts its landing
* page there, and a front door of any kind can. The worker used to cache
* whatever `/` returned at install and whatever HTML a navigation returned,
* and since app routes are answered from that copy first, a demo visitor who
* came back got the landing page on every route, for good. The app page is
* recognised by the asset list the build writes into it.
*/
const APP_PAGE_MARKER = 'id="ihasmail-assets"';
const isAppPage = (html) => typeof html === "string" && html.includes(APP_PAGE_MARKER);
/*
* The routes the app itself owns (App.tsx). Only these are answered from the
* kept page; anything else under the mount -- the root, a landing or farewell
* page in front of the app, a file -- goes to the network as it always did.
*/
const APP_ROUTE = /^\/(mail|search|contacts|calendar|files|settings|admin|login)(\/|$)/;
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
event.waitUntil(
caches.open(VERSION)
.then((c) => c.addAll(SHELL))
.then(() => fetch(`${BASE}/mail`, { credentials: "same-origin" }).then((res) => (res.ok ? refreshShell(res) : undefined)).catch(() => {}))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim())
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
.then(() => dropForeignShell())
.then(() => tidy())
.catch(() => {})
.then(() => self.clients.claim())
);
});
/*
* Keeping the cache to what the current build uses.
*
* Build assets are cached on first use and their names change with every
* build, and nothing used to take them out again: every deploy's chunks stayed
* in the browser for good. Worse, whatever the server answered was kept -- a
* 404 for a chunk asked for while a deploy was changing over became that
* chunk, from then on, in that browser.
*
* The rule now: only a successful response is cached, and whenever the app
* page changes, the assets it no longer names are dropped. A lazily loaded
* chunk the page does not name is dropped too, and fetched again the next time
* it is wanted -- a hash that did not change is still on the server.
*
* The cache name stays as it is. The same cache carries what the worker leaves
* for a tab to collect -- a push verification, a share, the facts it notifies
* from -- and a new name would throw those away along with the rubbish.
*/
const ASSETS = `${BASE}/assets/`;
const SHELL_KEY = `${BASE}/`;
function assetsNamedIn(html) {
const out = new Set();
for (const m of html.matchAll(/["']([^"']*\/assets\/[^"']+)["']/g)) {
try {
out.add(new URL(m[1], self.location).pathname);
} catch {
/* not a URL */
}
}
return out;
}
/**
* Drop failed responses, and assets the cached app page does not name. `also`
* is a page whose assets are kept as well: the one just replaced, which a tab
* opened from the kept copy may still be running.
*/
async function tidy(also = "") {
const cache = await caches.open(VERSION);
const shell = await cache.match(SHELL_KEY);
// Without a page to go by, which assets are current is unknown; keep them.
const keep = shell ? assetsNamedIn(await shell.text()) : null;
if (keep) for (const path of assetsNamedIn(also)) keep.add(path);
for (const req of await cache.keys()) {
const path = new URL(req.url).pathname;
if (path.startsWith(ASSETS)) {
if (keep && !keep.has(path)) {
await cache.delete(req);
continue;
}
}
const res = await cache.match(req);
if (res && !res.ok) await cache.delete(req);
}
}
/** A kept page that is not the app page -- left by an earlier worker -- is thrown away. */
async function dropForeignShell() {
const cache = await caches.open(VERSION);
const kept = await cache.match(SHELL_KEY);
if (kept && !isAppPage(await kept.text())) await cache.delete(SHELL_KEY);
}
/** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */
async function refreshShell(res) {
const html = await res.text();
if (!isAppPage(html)) return;
const cache = await caches.open(VERSION);
const prev = await cache.match(SHELL_KEY);
const prevHtml = prev ? await prev.text() : "";
if (prevHtml !== html) {
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
await tidy(prevHtml);
}
await precache(html);
}
/*
* Fetching the rest of the build before it is asked for.
*
* The app page lists every file of its build (see the asset-list plugin in
* vite.config.ts). Without this, the first time after a deploy that a reader
* opened the composer, settings or a viewer, it waited on the server for the
* code -- on a distant link, a visible pause. Now those files are fetched
* quietly once a page names them, a few at a time, and only those not held
* already; a load cut short is carried on at the next navigation, which calls
* this again. Language catalogs are left to be cached when used, and nothing
* is fetched ahead when the reader has asked the browser to save data.
*/
const PRECACHE_PARALLEL = 3;
function precacheList(html) {
const m = html.match(/<script type="application\/json" id="ihasmail-assets">([^<]*)<\/script>/);
if (!m) return [];
try {
const list = JSON.parse(m[1]).precache;
return Array.isArray(list) ? list.filter((p) => typeof p === "string" && p.startsWith(ASSETS)) : [];
} catch {
return [];
}
}
async function precache(html) {
if (self.navigator.connection && self.navigator.connection.saveData) return;
const cache = await caches.open(VERSION);
const wanted = [];
for (const path of precacheList(html)) if (!(await cache.match(path))) wanted.push(path);
const next = async () => {
for (let path = wanted.shift(); path; path = wanted.shift()) {
try {
const res = await fetch(path, { credentials: "same-origin" });
if (res.ok) await cache.put(path, res);
} catch {
/* offline, or a deploy changing over; the next navigation tries again */
}
}
};
await Promise.all(Array.from({ length: PRECACHE_PARALLEL }, next));
}
/*
* Where a share from the operating system is left for a tab to collect.
*
@@ -103,21 +254,52 @@ self.addEventListener("fetch", (event) => {
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith(`${BASE}/api/`)) return;
// Hashed build assets: cache-first.
if (url.pathname.startsWith(`${BASE}/assets/`)) {
// Hashed build assets: cache-first, and only what actually arrived.
if (url.pathname.startsWith(ASSETS)) {
event.respondWith(
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
const copy = res.clone();
caches.open(VERSION).then((c) => c.put(req, copy));
if (res.ok && res.type === "basic") {
const copy = res.clone();
event.waitUntil(caches.open(VERSION).then((c) => c.put(req, copy)).catch(() => {}));
}
return res;
}))
);
return;
}
// Navigations & everything else: network-first, fall back to cached shell.
/*
* Navigations: the kept app page at once, and the network's behind it.
*
* Every route in the app is the same page, and waiting on the server for it
* cost a full round trip before anything could start -- the longest single
* wait on a distant link. So a route is answered from the kept copy when
* there is one, and the fresh page is fetched alongside to replace it for
* next time. A page that is a build behind is caught the way it always was:
* the version check reloads it (lib/sw/staleBuild.ts), and the assets it
* names are kept for one more build so it can run until then.
*
* Only the app's own routes (APP_ROUTE). The root, a page in front of the
* app, and a file opened in a tab of its own go to the network as before. So
* does the first visit, which has no copy yet.
*/
if (req.mode === "navigate") {
event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`)));
const network = fetch(req).then((res) => {
// Every route is the same app page; a fresh one replaces the offline copy.
if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) {
event.waitUntil(refreshShell(res.clone()).catch(() => {}));
}
return res;
});
const appRoute = APP_ROUTE.test(url.pathname.slice(BASE.length));
event.respondWith((async () => {
const kept = appRoute ? await caches.match(SHELL_KEY) : undefined;
if (kept) {
event.waitUntil(network.catch(() => {}));
return kept;
}
return network.catch(() => caches.match(SHELL_KEY));
})());
return;
}
event.respondWith(fetch(req).catch(() => caches.match(req)));
@@ -272,6 +454,14 @@ self.addEventListener("push", (event) => {
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
event.waitUntil((async () => {
/*
* Someone reading the app already knows. A focused, visible window of this
* app gets its new mail from its own event stream, so a notification on
* top of it is a second telling of the same thing (#375). Chrome does not
* require one while the site is in the foreground.
*/
const windows = await self.clients.matchAll({ type: "window" });
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
const facts = await readFacts();
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
/*
@@ -287,8 +477,10 @@ self.addEventListener("push", (event) => {
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
if (!emails.length) {
// A StateChange, or a payload too large to carry the message. Say
// something true rather than inventing a sender.
// A delivery from a server that sends StateChange rather than EmailPush
// -- the subscription asks for `EmailDelivery` only, so it is new mail --
// or a payload too large to carry the message. Say something true
// rather than inventing a sender.
await self.registration.showNotification(strings.newMail, {
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
});
@@ -308,7 +500,8 @@ self.addEventListener("push", (event) => {
// be drawn.
actions: email.id ? actionsFor(facts) : [],
data: {
url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`,
// The route names a conversation, and `m` the message in it.
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
id: email.id || null,
title,
accountId: facts?.accountId ?? null,
+6 -8
View File
@@ -16,12 +16,12 @@ import { LoginPage } from "@/views/Login";
import { AppShell } from "@/views/AppShell";
import { MailView } from "@/views/mail/MailView";
import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify";
import { publishWorkerFacts } from "@/lib/swFacts";
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
import { publishWorkerFacts } from "@/lib/sw/swFacts";
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
import { BASE_PATH, withBase } from "@/lib/basePath";
@@ -260,10 +260,8 @@ function AuthedApp() {
});
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
useEffect(() => {
void import("@/lib/notify").then((m) => {
m.setBaseTitle(appName);
setUnreadBadge(inboxUnread);
});
setBaseTitle(appName);
setUnreadBadge(inboxUnread);
}, [inboxUnread, appName]);
/*
@@ -284,7 +282,7 @@ function AuthedApp() {
// Request notification permission lazily when enabled
const notif = useSettings((s) => s.settings.desktopNotifications);
useEffect(() => {
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
if (notif) void requestNotificationPermission();
}, [notif]);
// Nothing worth painting until the account's settings are in force; see the
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { INBUXA_CAP, legacyProtocolsOff } from "../client";
import type { JmapSession } from "../types";
/**
* The server says, per account, whether legacy mail protocols are off for it
* (legacy-protocols LP-19, contract C-1). Anything short of a plain
* "disabled" -- an older server, another server, no session yet -- reads as
* on, so the notice never appears where it isn't true.
*/
describe("legacyProtocolsOff", () => {
const session = (cap: Record<string, unknown> | undefined) =>
({
accounts: { a: { name: "[email protected]", accountCapabilities: cap ? { [INBUXA_CAP]: cap } : {} } },
}) as unknown as JmapSession;
it("is on only when the account's capability says disabled", () => {
expect(legacyProtocolsOff(session({ legacyProtocols: "disabled" }), "a")).toBe(true);
expect(legacyProtocolsOff(session({ legacyProtocols: "enabled" }), "a")).toBe(false);
});
it("reads an older or other server, or no session, as on", () => {
expect(legacyProtocolsOff(session({ logo: null }), "a")).toBe(false);
expect(legacyProtocolsOff(session(undefined), "a")).toBe(false);
expect(legacyProtocolsOff(session({ legacyProtocols: "disabled" }), "b")).toBe(false);
expect(legacyProtocolsOff(null, "a")).toBe(false);
expect(legacyProtocolsOff(session({ legacyProtocols: "disabled" }), null)).toBe(false);
});
});
+19 -1
View File
@@ -22,6 +22,21 @@ export const CAP = {
/** Stalwart's own capability, which carries its `x:` registry methods. */
export const STALWART_CAP = "urn:stalwart:jmap";
/** INBUXA's own capability (contract C-1), on the signed-in account. */
export const INBUXA_CAP = "urn:inbuxa:jmap";
/**
* Whether legacy mail protocols -- IMAP, POP3, ManageSieve, sending from mail
* apps -- are off for this account: the stricter of the server's switch and
* its organization's, as the server reports it (legacy-protocols LP-19). Any
* server that doesn't say reads as on.
*/
export function legacyProtocolsOff(session: JmapSession | null, accountId: Id | null): boolean {
if (!session || !accountId) return false;
const cap = session.accounts[accountId]?.accountCapabilities?.[INBUXA_CAP] as { legacyProtocols?: string } | undefined;
return cap?.legacyProtocols === "disabled";
}
export class JmapMethodError extends Error {
constructor(
public readonly method: string,
@@ -104,6 +119,8 @@ export class JmapClient {
private callCounter = 0;
private unauthHandlers = new Set<() => void>();
private stateHandlers = new Set<(sessionState: string) => void>();
/** The last session state announced, so a burst of replies announces it once. */
private announcedState: string | null = null;
get maxCallsInRequest(): number {
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
@@ -255,7 +272,8 @@ export class JmapClient {
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
if (createdIds) body.createdIds = createdIds;
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
if (res.sessionState && this.session && res.sessionState !== this.session.state) {
if (res.sessionState && this.session && res.sessionState !== this.session.state && res.sessionState !== this.announcedState) {
this.announcedState = res.sessionState;
for (const fn of this.stateHandlers) fn(res.sessionState);
}
return res;
+3 -1
View File
@@ -31,6 +31,8 @@ export interface JmapSession {
maxUploadBytes: number;
sessionId: string;
loginName: string;
/** "oauth" when signed in on the mail server's page (ihasmail-inbuxa); absent from older servers. */
signIn?: "oauth" | "password";
remember: boolean;
/** Locale configured for the account in Stalwart, if the server exposes it. */
userLocale?: string | null;
@@ -38,7 +40,7 @@ export interface JmapSession {
server?: {
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
edition?: string | null;
/** Where Stalwart's own administration is (STALWART_ADMIN_URL), for a session that may administer. */
/** Where Stalwart's own administration is (ADMIN_URL), for a session that may administer. */
adminUrl?: string | null;
/** SHOW_ENTERPRISE_NOTICES: an Enterprise-only section says so even on Enterprise. */
enterpriseNotices?: boolean;
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
import { displayName, formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
describe("address parsing", () => {
it("parses mixed lists", () => {
@@ -55,3 +55,14 @@ describe("mailto URLs", () => {
expect(m.to).toHaveLength(1);
});
});
describe("names that reorder themselves", () => {
const spoof = { name: "[email protected]\u202E", email: "[email protected]" };
it("lose their direction controls when displayed", () => {
expect(displayName({ name: "\u202Egnp.exe\u202C Ann", email: "[email protected]" })).toBe("gnp.exe Ann");
expect(formatAddress(spoof)).toBe("[email protected] <[email protected]>");
});
it("fall back to the address when nothing else is left", () => {
expect(displayName({ name: "\u200F\u202E", email: "[email protected]" })).toBe("[email protected]");
});
});
@@ -0,0 +1,73 @@
/*
* An instance renamed with APP_NAME should be called by its name everywhere,
* not only on the sign-in page and in the title bar. So no sentence shown to
* a person may write "ihasmail" into itself: it takes the name as {app}.
*
* The exceptions are the places where "ihasmail" is not the app's name but a
* literal a person could go and look at: the Files folder, the Sieve script
* and the project's own address. Renaming those would rename real data.
*/
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
/** Strings that name a stored thing, not the app. */
const LITERALS = [
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.",
"“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.",
"Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.",
"ihasmail.org",
"ihasmail",
];
function sources(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
if (name === "locales" || name === "__tests__") continue;
sources(path, out);
} else if (/\.tsx?$/.test(name)) {
out.push(path);
}
}
return out;
}
/** Every translated string in a file, however `t` was imported. */
function translatedStrings(code: string): string[] {
return [...code.matchAll(/\b(?:t|tNode|translate)\(\s*"((?:[^"\\]|\\.)*)"/g)].map((m) =>
JSON.parse(`"${m[1]}"`),
);
}
describe("text that names the app", () => {
it("takes the name as {app} instead of writing ihasmail into the sentence", () => {
const offenders: string[] = [];
for (const file of sources(SRC)) {
for (const s of translatedStrings(readFileSync(file, "utf8"))) {
if (s.includes("ihasmail") && !LITERALS.includes(s)) {
offenders.push(`${file.slice(SRC.length)}: ${s.slice(0, 60)}`);
}
}
}
expect(offenders).toEqual([]);
});
it("keeps a placeholder in every translation of those strings", () => {
const catalogs = readdirSync(join(SRC, "locales")).filter((f) => f.endsWith(".ts") && f !== "index.ts");
const wrong: string[] = [];
for (const name of catalogs) {
const code = readFileSync(join(SRC, "locales", name), "utf8");
for (const m of code.matchAll(/^\s*"((?:[^"\\]|\\.)*)": "((?:[^"\\]|\\.)*)",$/gm)) {
const key = JSON.parse(`"${m[1]}"`);
const value = JSON.parse(`"${m[2]}"`);
// A key that takes the name must not hard-code it in the translation.
if (key.includes("{app}") && value.includes("ihasmail")) wrong.push(`${name}: ${key.slice(0, 50)}`);
}
}
expect(wrong).toEqual([]);
});
});
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { contactFromAddress, nameParts } from "../contacts";
import { contactFromAddress, contactPhoto, nameParts, withPhoto } from "../contacts";
import type { ContactCard } from "@/jmap/types";
const parts = (name: string | null, email = "[email protected]") =>
@@ -34,3 +34,35 @@ describe("contactFromAddress", () => {
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
});
});
/**
* #376: a photo saved as a `blobId` was refused by Stalwart, which only takes
* the `uri` form. Saving one must also leave a card's other media alone.
*/
describe("withPhoto", () => {
const photo = { dataUrl: "data:image/jpeg;base64,AAAA", type: "image/jpeg" };
it("puts the photo in as a data URI, never a blob id", () => {
const media = withPhoto(undefined, photo)!;
const [m] = Object.values(media);
expect(m).toEqual({ "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: "image/jpeg" });
expect(m).not.toHaveProperty("blobId");
});
it("replaces an existing photo and keeps a logo", () => {
const media = withPhoto({ old: { kind: "photo", blobId: "b1" }, l: { kind: "logo", uri: "data:image/png;base64,BB" } }, photo)!;
expect(Object.values(media).filter((m) => m.kind === "photo")).toHaveLength(1);
expect(media.old).toBeUndefined();
expect(media.l).toEqual({ kind: "logo", uri: "data:image/png;base64,BB" });
});
it("removes only the photo, and clears media when nothing is left", () => {
expect(withPhoto({ p: { kind: "photo", uri: "data:x" }, s: { kind: "sound", uri: "data:y" } }, null)).toEqual({ s: { kind: "sound", uri: "data:y" } });
expect(withPhoto({ p: { kind: "photo", uri: "data:x" } }, null)).toBeNull();
});
it("is read back by contactPhoto", () => {
const card = { id: "c1", media: withPhoto(undefined, photo) } as unknown as ContactCard;
expect(contactPhoto(card, "a1")).toBe(photo.dataUrl);
});
});
+2 -2
View File
@@ -6,8 +6,8 @@
* the joining is Intl's rather than a hardcoded " and ".
*/
import { describe, expect, it } from "vitest";
import { describeRule as describeSieve } from "../sieve";
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
import { describeRule as describeSieve } from "../sieve/sieve";
import { describeRule as describeRecurrence, weekdayOptions } from "../calendar/recurrence";
import { setUiLanguageForFormatting } from "../datetime";
import { setCatalog } from "../i18n";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { hasHtmlAlternative } from "../html";
import { hasHtmlAlternative } from "../text/html";
/*
* The rule: `htmlBody` is derived, so its presence proves nothing. Only the
+22 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isTextEntry, keyboard } from "@/lib/keyboard";
import { isTextEntry, keyboard } from "@/lib/input/keyboard";
/*
* Shortcuts after a click on a checkbox (#260).
@@ -11,8 +11,8 @@ import { isTextEntry, keyboard } from "@/lib/keyboard";
* swallows a keystroke in the first place.
*/
const pressFrom = (el: Element, key: string) => {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
const pressFrom = (el: EventTarget, key: string, init?: KeyboardEventInit) => {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
el.dispatchEvent(e);
return e;
};
@@ -92,3 +92,22 @@ describe("shortcuts with a checkbox focused", () => {
expect(handler).not.toHaveBeenCalled();
});
});
describe("shifted letter shortcuts", () => {
it("matches Shift+I and Shift+U bindings", () => {
const read = vi.fn();
const unread = vi.fn();
pop = keyboard.pushScope("test", [
{ keys: "shift+i", description: "Mark as read", group: "Actions", handler: read },
{ keys: "shift+u", description: "Mark as unread", group: "Actions", handler: unread },
]);
const readEvent = pressFrom(window, "I", { shiftKey: true });
const unreadEvent = pressFrom(window, "U", { shiftKey: true });
expect(read).toHaveBeenCalledOnce();
expect(unread).toHaveBeenCalledOnce();
expect(readEvent.defaultPrevented).toBe(true);
expect(unreadEvent.defaultPrevented).toBe(true);
});
});
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { comboOf, keyboard } from "@/lib/keyboard";
import { comboOf, keyboard } from "@/lib/input/keyboard";
/*
* A "keydown" that carries no key. Chrome's password autofill dispatches one
@@ -36,7 +36,7 @@ describe("a keydown with no key", () => {
it("leaves real keys alone", () => {
expect(comboOf(new KeyboardEvent("keydown", { key: "e" }))).toBe("e");
expect(comboOf(new KeyboardEvent("keydown", { key: "E", shiftKey: true }))).toBe("E");
expect(comboOf(new KeyboardEvent("keydown", { key: "E", shiftKey: true }))).toBe("shift+e");
expect(comboOf(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }))).toMatch(/enter$/);
});
});
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { keyboard } from "@/lib/keyboard";
import { keyboard } from "@/lib/input/keyboard";
/*
* Two-key sequences against the single keys they start with.
+18 -2
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
import { SW_CACHE_NAME } from "@/lib/swCache";
import { shareSummary, collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
/**
* The handoff, from the tab's side. The worker's half cannot be exercised here
@@ -112,3 +112,19 @@ describe("the body a share turns into", () => {
expect(shareBody({ text: "a thought", url: "" })).toBe("a thought");
});
});
describe("shareSummary", () => {
const file = (name: string) => new File(["x"], name);
it("gives the title, the text and link together, and the file names", () => {
expect(shareSummary({ title: " Trip ", text: "See this", url: "https://example.com", files: [file("a.jpg")] })).toEqual({
title: "Trip",
preview: "See this https://example.com",
files: ["a.jpg"],
});
});
it("shortens a long text rather than showing all of it", () => {
const { preview } = shareSummary({ title: "", text: "word ".repeat(100), url: "", files: [] });
expect(preview.length).toBeLessThanOrEqual(160);
expect(preview.endsWith("…")).toBe(true);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/input/touch";
describe("navSwipeThreshold", () => {
it("asks for more travel than a row swipe does, at every width", () => {
+6
View File
@@ -79,6 +79,12 @@ describe("isTnef", () => {
});
describe("parseTnef", () => {
it("takes the direction overrides out of a name", () => {
const out = parseTnef(tnef(file("x.bin", "MZ", [
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "Invoice_\u202Efdp.exe" }]) },
])));
expect(out[0]!.name).toBe("Invoice_fdp.exe");
});
it("pulls one attachment out, with its name and bytes", () => {
const out = parseTnef(tnef(file("report.pdf", "hello")));
expect(out).toHaveLength(1);
+7 -4
View File
@@ -1,4 +1,5 @@
import type { EmailAddress } from "@/jmap/types";
import { withoutBidiControls } from "@/lib/text/text";
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
@@ -48,9 +49,10 @@ export function parseOne(raw: string): EmailAddress | null {
export function formatAddress(a: EmailAddress | null | undefined): string {
if (!a) return "";
if (!a.name) return a.email;
const needsQuote = /[,;<>"()\\]/.test(a.name);
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name;
const clean = a.name ? withoutBidiControls(a.name) : "";
if (!clean) return a.email;
const needsQuote = /[,;<>"()\\]/.test(clean);
const name = needsQuote ? `"${clean.replace(/(["\\])/g, "\\$1")}"` : clean;
return `${name} <${a.email}>`;
}
@@ -60,7 +62,8 @@ export function formatAddressList(list: EmailAddress[] | null | undefined): stri
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
if (!a) return fallback;
if (a.name?.trim()) return a.name.trim();
const name = a.name ? withoutBidiControls(a.name).trim() : "";
if (name) return name;
return a.email || fallback;
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/admin/adminAccess";
const set = (...p: string[]) => permissionSet(p);
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { client, JmapMethodError } from "@/jmap/client";
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/adminDashboard";
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/admin/adminDashboard";
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/adminDirectory";
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/admin/adminDirectory";
describe("setting a password", () => {
it("writes into the existing password credential, keeping its place", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/adminDomains";
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/admin/adminDomains";
/**
* Written the way Stalwart's BIND serializer writes it (dns-update's
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/adminGroups";
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/admin/adminGroups";
describe("group membership", () => {
it("is a patch to each member, one pointer each, so no other membership moves", () => {
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { CONFIRM_PHRASE, impactEntries, parseTenantLegacy, phraseMatches } from "../adminLegacyProtocols";
describe("a tenant's legacy mail protocols switch, as the server sends it", () => {
it("reads the switch, and tells an older server from nobody", () => {
expect(parseTenantLegacy({ legacyProtocols: "disabled", recentLegacyUse: [] })).toEqual({ off: true, recent: [] });
expect(parseTenantLegacy({ legacyProtocols: "enabled" })).toEqual({ off: false, recent: null });
});
it("puts each account on the panel once, with every protocol and its latest use", () => {
const { recent } = parseTenantLegacy({
recentLegacyUse: [
{ accountId: "a", name: "[email protected]", protocol: "submission", lastUsedAt: 100 },
{ accountId: "a", name: "[email protected]", protocol: "imap", lastUsedAt: 300 },
{ accountId: "b", name: "[email protected]", protocol: "pop3", lastUsedAt: 200 },
{ accountId: "c", name: "broken" },
],
});
expect(impactEntries(recent!)).toEqual([
{ name: "[email protected]", protocols: ["IMAP", "SMTP"], lastUsedAt: 300 },
{ name: "[email protected]", protocols: ["POP3"], lastUsedAt: 200 },
]);
});
it("takes only the exact phrase", () => {
expect(phraseMatches(CONFIRM_PHRASE)).toBe(true);
expect(phraseMatches(` ${CONFIRM_PHRASE}`)).toBe(false);
expect(phraseMatches(CONFIRM_PHRASE.toUpperCase())).toBe(false);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/adminLists";
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/admin/adminLists";
describe("a mailing list's recipients", () => {
it("are saved as what was added and removed, one pointer each", () => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { permissionSet } from "@/lib/adminAccess";
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/adminRoles";
import { permissionSet } from "@/lib/admin/adminAccess";
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/admin/adminRoles";
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
const roles = new Map<string, DirectoryRole>([
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/adminTenants";
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/admin/adminTenants";
describe("a tenant's limits", () => {
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {

Some files were not shown because too many files have changed in this diff Show More