Commit Graph
11 Commits
Author SHA1 Message Date
jcoffey-dev 0277b5b6a8 Send the length of the bytes we are actually sending
A gzip response is decompressed before the blob proxy sees the body, but
its content-length still describes the compressed bytes. Copying that
header onto the longer body made the browser stop reading that many bytes
in and call the download complete, so files arrived truncated with nothing
reporting a failure.

It took a hop that compresses to show up, and one that only compresses
above a threshold to look like a race: a Sieve script stayed intact for two
rules and came back cut off mid-rule once the third pushed it past 1 KiB.

Ask upstream for identity, and forward no length at all rather than one
that describes different bytes.
2026-08-30 13:46:05 -07:00
jcoffey-dev f72c67864e Let the container run with nothing writable
The server writes to one path and no other: SESSION_FILE, from sessions.ts.
Everything else it touches on disk it only reads. So a container with a
read-only root filesystem already works -- except that `VOLUME ["/data"]`
quietly undid it. Docker acts on that directive: a container started without
`-v` gets an anonymous volume mounted there anyway, writable even under
`--read-only`. It persisted nothing across a redeploy, since each new container
got a fresh empty volume, and it left an orphan behind every time one was
replaced. Deployments that want the sessions to survive already say so
themselves -- docker-compose.yml and deploy.example.sh both mount a named
volume -- so removing the line changes nothing for them.

IMMUTABLE=1 asserts that this is how the instance is running. It is checked
rather than believed: the server refuses to start if SESSION_FILE is still set,
or if the filesystem it is installed on turns out to be writable. Left
unchecked the misconfiguration is silent, because persisting sessions is
best-effort -- a read-only /data costs one warning at the first sign-in and
nothing more until the instance is replaced and everyone is signed out.

SessionBackend names what the rest of the server asks of a session store, and
`sessions` in app.ts is typed as it. Nothing changes today; SessionStore is
still the only implementation. It is there so the OAuth work is written against
the interface rather than the class, and so the interface can record which of
its methods a stateless backend could satisfy alone: create, resolve, reseal
and destroy each touch one session, while listForUser and destroyAllForUser
have to reach sessions other than the caller's. The second of those carries the
guarantee that changing a password invalidates the sessions still holding the
old one, which is why it needs a registry -- Stalwart's token registry, once
sign-in goes through OAuth.
2026-08-27 21:48:23 -07:00
jcoffey-dev d98c425a9a Stop pretending the two-factor field can work
Signing in with a two-factor code failed with a bare 401 and "Invalid
credentials", which sent the user off to check a password that was
perfectly good (#75). It cannot work, and the app already knew.

Stalwart accepts a TOTP code only through an OAuth flow -- its own web
interface is an OAuth client, which is why signing in *there* succeeds --
and it offers only the authorization-code and device flows. There is no
password grant, so a client holding a username and password has nowhere
to exchange them plus a code for a token. The concatenated
`password$code` form this README claimed was accepted is not a route the
server has, and appears never to have been. What was verified live on
0.16.19 was enabling and disabling 2FA, never signing in with a code.

The contradiction was already in the codebase: turning 2FA *on* mints an
app password and reseals the session onto it, precisely because a plain
password stops working from that moment. The sign-in page was the one
place still assuming otherwise.

Three changes, no new capability:

  - A 401 on a sign-in that carried a code now says what is happening
    and where to go instead, and says the password is probably fine.
    A sign-in without a code is untouched, so an ordinary typo still
    reads as an ordinary typo.

  - The field stays, and is honest about itself. Removing it would leave
    someone with 2FA finding nothing at all, which is worse than finding
    a field that explains the situation and points at app passwords.

  - The README's claim is corrected rather than quietly dropped, and
    real 2FA support is written into the roadmap as what it is: an OAuth
    implementation, handing sign-in to Stalwart and holding a refresh
    token instead of a sealed password.
2026-08-26 14:46:08 -07:00
jcoffey-dev bf70ba9df0 Give builds a version number
ihasmail called itself "2.0" on the About page and "2.0.0" from
/api/health, both hardcoded, in four places that had drifted from each
other and from anything meaningful. A build now says what it is:

  ihasmail v2.16.57
             |  |  |
             |  |  the pull request the commit came from
             |  the Stalwart generation this build targets -- 0.16
             ihasmail's own major

The first two are the version in the root package.json, so there is a
single place to bump them, and 16 becomes 17 when ihasmail moves to
Stalwart 0.17. Dropping 0.15 is what makes that middle number honest:
while two generations were supported it could not have been either.

The pull request number comes from git at build time and is never
written back into the tree. It cannot be: it does not exist until the
pull request has merged, so a committed version would always describe a
merge that had not happened yet, and every open branch would collide on
the same line. A commit that did not come through a pull request carries
the last number plus its own short SHA -- 2.16.57+g1fa6578 -- which says
it is past that pull request rather than quietly claiming to be it.

.dockerignore excludes .git on purpose, so an image build cannot work
any of this out. It takes --build-arg IHASMAIL_VERSION instead, which
the build stage bakes into the bundle and the runtime stage keeps as an
environment variable for the server. Left out, it falls back to the base
version from package.json rather than failing -- so a version with no PR
number on it means whoever built the image did not pass one.

scripts/ is copied into the runtime image because the server resolves
its version through it. There is no git in there to ask, which is the
fallback's whole purpose.

Verified: 2.16.57 in the bundle and from /api/health on a dev checkout;
the same after a real docker build --build-arg, from inside the
container; and 2.16.0 rather than a crash when the arg is left off.

Note for deploying: ihasmail-deploy.sh on the host builds without the
argument and will produce 2.16.0 until it passes
--build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)".
2026-08-26 10:00:29 -07:00
jcoffey-dev 94bf42cfda Drop Stalwart 0.15 support
ihasmail spoke to two generations of Stalwart that are less alike than
their version numbers suggest: 0.16 replaced the REST management API with
JMAP registry objects, changed the shape of FileNode, split its rights
up, and moved configuration into the store. Carrying both meant 34 branch
points across nine files, a 92-line compatibility shim whose only job was
telling them apart, a parallel REST implementation of every credential
operation, and a mock that had to model both.

The branches were not the real cost. The cost was that a wrong answer
about which generation had answered always had somewhere to fall back to,
so it failed quietly rather than loudly: one capability looked for in the
wrong place downgraded every real 0.16 server onto the 0.15 path, which
posted the current password to an endpoint 0.16 had removed, reported the
wrong generation on About, and ran Files on the older code. It reached
production and was recorded as verified when it was not. The mock
mirrored the same wrong placement, which is why the tests agreed.

Removed: the filenode compatibility shim, the dual "registry" | "legacy"
backend in account.ts, the pre-0.16 generation in AccountInfo and
everything that read it, the mock's LEGACY mode and dev:mock:legacy, and
the three test files that existed only to pin 0.15 behaviour.

Sign-in now refuses an older server by name, once, rather than letting
Files, the account locale and credentials each fail in their own way with
nothing connecting them. It says the credentials were fine -- someone
hitting this has typed a correct password, and telling them otherwise
sends them round in circles -- and names the tag to build from. Four
tests cover it, including that no session cookie is minted and that bad
credentials on such a server are still a plain 401.

Two fallbacks went that were not strictly about 0.15, and both for the
same reason the removal is happening. Files no longer answers a refused
filter or sort by fetching every node in the account, which would hide a
real fault behind a performance cliff nobody would notice. And the app
folder lookups now filter on parentId/isTopLevel alone and match names
client-side, since `name` is not a filter Stalwart is known to implement
and one it does not know fails the whole query rather than being ignored.

The last release that runs on 0.15 is tagged stalwart-0.15-support.

Verified against the mock end to end: sign-in, the Files tree on the 0.16
path with the app folder hidden, and self-service credentials over the
registry. 226 web + 75 server tests pass; typecheck and build clean.
2026-08-26 09:51:03 -07:00
jcoffey-dev 7b05322577 Make the AGPL's source offer point at the source being run
Three things a licence audit turned up. None of them is a conflict --
every one of the 182 installed packages is permissive, and the relicence
was within the copyright holder's gift -- but all three are ways the
AGPL fails to stick.

The offer was hard-coded to this repository. Section 13 asks whoever
runs a modified version to offer *that* version's source, so every
deployment with a patch in it was pointing at the wrong tree, and would
have gone on doing so unless its operator noticed and edited the About
page. SOURCE_URL now sets it, alongside APP_NAME, and both the sign-in
page and About read it.

The offer was also only visible after signing in. Whoever is looking at
the sign-in form is interacting with the program over a network too, so
the footer carries it now.

And the two workspace packages declared no licence at all. Private, so
npm never minded, but anything reading the tree saw a blank where the
rest of the project says AGPL-3.0-or-later.

Checked both ways round: with SOURCE_URL set to a fork, the sign-in page
and About both point at the fork; with it unset, both fall back to this
repository.
2026-08-25 13:42:11 -07:00
jcoffey-dev c5f2e2c7f2 Harden four things the audit turned up
**The login rate limiter could be sidestepped.** X-Forwarded-For is a list each
hop appends to, and nginx's $proxy_add_x_forwarded_for appends ours — so a
client sending "X-Forwarded-For: 1.2.3.4" arrives as "1.2.3.4, <their real
address>". Reading the leftmost entry, as we did, handed the caller a
rate-limit key they could change per request: unlimited password guessing
against a deployment that looks correctly configured. Read from the right
instead, skip hops that are themselves trusted proxies, and believe the header
only when the peer is one (loopback and the private ranges by default,
TRUSTED_PROXIES to be explicit).

**The upload cap was a suggestion.** It read content-length, which a chunked
request simply omits. Count the bytes through a stream, as the image proxy
already does.

**App password secrets were drawn with a modulo.** 256 is not a multiple of 33,
so the first 25 characters of the alphabet came up on 8 byte values and the
last 8 on only 7. Rejection sampling instead. The test weighs the whole tail of
the alphabet rather than single characters, because a 7/8 skew is invisible
per character against the noise — and it does fail when the bias is put back.

**Upstream headers were relayed wholesale.** Anything the mail server set —
cookies, auth challenges, CORS grants — landed on our origin, where it means
something else. Allowlist what is actually wanted.
2026-08-24 11:10:15 -07:00
jcoffey-dev c145858bbe Read the locale where users can actually read it, and say which Stalwart answered
The account locale came from `x:Account/get`, which needs `sysAccountGet` — a
permission the built-in `user` role is not given, so the setting silently fell
back to the browser locale for exactly the people most likely to have set it.
Stalwart 0.16 carries the same field on `x:AccountSettings`, whose
`sysAccountSettingsGet` *is* part of that role. Both are now asked for in one
request and whichever answers wins, so admins and older servers keep working.

That pair of replies also says which generation we are talking to: only 0.16+
can parse the method name at all. About now reports that, plus the edition
from /api/account where the server offers it. It does not report a version
number because Stalwart does not publish one to clients — it hardcodes a
public "1.0.0" and keeps the real version to its SMTP internals — so the
screen says what was actually detected rather than inventing precision.

Also adds a light/dark toggle to the top bar, left of the settings button. The
stored setting is three-way, so the button acts on the theme actually on
screen: whichever one you see, a click gives you the other. Choosing "match
system" again stays in Settings › Appearance, where a three-way choice belongs.
2026-08-24 08:35:22 -07:00
jcoffey-dev 0f1fbcff93 Manage your own password, app passwords and 2FA
Settings › Security grows three working sections instead of a note telling
people to use Stalwart's own portal.

Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has
the x:AccountPassword singleton and x:AppPassword registry objects over JMAP,
while 0.15.x has the /api/account/auth REST endpoint. Which one answers the
probe is the only reliable way to tell them apart, and the result is cached
per session. The built-in `user` role already grants sysAccountPassword* and
sysAppPassword*, so no administrator setup is needed.

Two problems are worth calling out, because both would bite a user hard:

Stalwart validates the credentials already on the account when 2FA is turned
on and never checks the new secret, so an authenticator that was mistyped or
out of step would lock someone out of their mailbox at the next sign-in. We
verify a code against the new secret ourselves first (RFC 6238, tested against
the spec's vectors) and only then ask the server to store anything.

Every proxied call re-authenticates with the credential sealed into the
session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with
it — which we cannot produce between requests. Turning 2FA on would therefore
sign the user out of the browser they just turned it on in. App passwords
authenticate without a second factor, so the session is moved onto one minted
for this browser, and the session cookie is re-sealed with it. The order
matters: it is minted while the old credential still works, and revoked again
if enabling then fails.

Password changes re-seal this session too and drop the others, whose sealed
copies of the old password would fail on their next call.

The mock now enforces what a real server does — current password, password
policy, a TOTP code on every request once 2FA is on, app passwords exempt —
so the whole flow is exercised in tests rather than only by hand.
2026-08-24 08:18:47 -07:00
jcoffey-dev d82ff15921 Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by
three settings (Settings > General > Locale):

- Language & region: automatic, or any of the 618 locales CLDR has data for,
  each named in its own language and script (web/src/lib/locales.ts, generated
  by probing Intl over the subtag space).
- Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025,
  or ISO 8601 2025-11-22.
- Time format: automatic (locale), 24-hour, or 12-hour.

Automatic takes the locale Stalwart has for the account, read best-effort at
login via x:Account/get (urn:stalwart:jmap) and passed to the client in the
session; servers without the capability, or that deny sysAccountGet to a
regular user, fall back to the browser locale. POSIX forms are normalised
(de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS,
uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are
dropped and a script the locale already implies is not appended.

Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under
ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so
one line never mixes digit systems.

Rewired: message list and headers, quoted reply headers, calendar (titles,
weekday and hour gutters, mini calendar, agenda, popovers, invite cards,
free/busy), contacts, files, sessions. No raw toLocale*String date calls are
left in web/src.

Native <input type="datetime-local"> pickers always follow the browser locale
and cannot be restyled by a page, so the out-of-office fields echo the entered
instant in the chosen format underneath.

Also: month-grid day labels no longer wrap when they hold a date, and the mock
server serves x:Account/get (MOCK_LOCALE, default en_US).

Closes #1
2026-08-23 12:32:11 -07:00
jcoffey-dev 645b8b510f ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
2026-08-23 01:07:13 -07:00