6431ec87f543c3dc8f18d71fbeff5766fdc27dae
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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)".
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |