607afeb4ad5c93da52e7d0cd8141e7ed7678f04b
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
607afeb4ad |
Do not spend login attempts on an outage nobody caused
ihasmail runs in its own container, usually on its own host, so Stalwart being briefly unreachable is an ordinary Tuesday. Sign-in handled it almost right: a 401 is invalid_credentials, a timeout is 504 and anything else is 502, none of which reads as a rejected password. What it got wrong was the counting. RateLimiter.check() consumes an attempt when it is called, and it is called before the upstream is contacted; reset() only runs on success. So every try against an unreachable server burned a credential attempt, and after ten of them the person was locked out for the rest of the fifteen-minute window -- including after the server came back. A thirty-second blip became a quarter-hour lockout, and the second failure was entirely ihasmail's own doing. A 401 is a judgement about the password and stays counted. A 502 or 504 is the upstream failing to answer, says nothing about the credentials, and is now refunded -- one attempt back, not the key cleared, so a run of real failures with an outage in the middle still adds up. The old-server refusal refunds too: those credentials were accepted. Both guessing keys are refunded, not just the username one. Refunding only that would not have fixed it -- ten retries still spend the per-address budget, and behind one office NAT that budget belongs to the whole building, so a company-wide outage would lock out the company. Which needs a backstop, because "not counted" must not mean "unlimited": each attempt still costs an outbound connection that may sit there until UPSTREAM_TIMEOUT, and an outage is the one moment the endpoint is cheapest to abuse. So there is a second ceiling per address, twenty times looser and never refunded. A person retrying will not come near it; something hammering will. Both messages now say the quiet part -- "This is not a problem with your password" -- for somebody already worried they have forgotten it. Closes #239. |
||
|
|
457ea53ca3 |
Let an installation seed and lock user settings
The first two thirds of #207. A school wanting "warn about outside senders" on for three thousand pupils cannot ask three thousand pupils, and the reporter is right that this is a company policy rather than a preference. Two powers, and the difference between them is the whole request. `defaults` seed an account that has never had settings of its own and can be changed afterwards like anything else -- a starting point, not a rule. `enforced` are reapplied on every load and cannot be changed at all. Enforced controls stay visible and go dead, with a line saying why. The issue asked for that by name: a control that is simply missing reads as a bug to somebody who has used ihasmail without a policy. The lock is in the settings store rather than only on the controls. There is one door -- `update` -- and putting it there means an imported settings file, a settings file synced from a device that predates the policy, and a control somebody adds later and forgets to check are all covered by construction. Reset goes back to the installation's answer rather than to ihasmail's, so it cannot be a way around a policy either. Configured by environment variable or by a file, because ihasmail's own production runs read-only with no volume: an installation that cannot mount a file can still set a variable. Keys this build does not have are dropped, the same rule an imported settings file already gets -- a policy written against a newer ihasmail must not put a setting nothing reads into everybody's synced settings file. Malformed JSON stops the server rather than quietly doing nothing, since a policy that silently did not apply is indistinguishable from the feature not working. Tier three -- enforcing a setting once while still letting readers change it afterwards -- is not here. It needs a decision the reporter and I have not made yet, and it is the only part that stores anything new. Refs #207. |
||
|
|
4d18d94b63 |
Subscribe to a calendar published at a URL
A timetable, a rota, a public holiday list: the calendars people are given as a link, which ihasmail could not show at all. Nothing is stored. The document is fetched when the calendar is opened and parsed in the browser; the server keeps no copy, no cache and no schedule, which is what lets an immutable container serve this. There is no timer either -- there is nowhere to run one -- so the guarantee is that a subscription is as current as the last time somebody looked, which is also when it matters. That is said plainly rather than implied. The fetch has to happen on the server: a calendar URL belongs to whoever published it and almost none of them send CORS headers. That makes it the second place this app knocks on a door somebody else chose, so the guard the image proxy has always had was lifted out and both now call it. A second SSRF implementation is how one of them ends up missing a case; this way there is one, and the extraction is covered by the image proxy's own tests still passing unchanged. webcal: is understood, because that is how these are published, and it is read as https: rather than waved past the checks -- a webcal URL pointing at loopback is refused exactly like an http one. Recurrence is deliberately not expanded. RRULE is a small language with a lot of edge cases, and a subscription quietly showing the wrong dates would be worse than one showing the first occurrence and saying so. The parser is a subscription parser rather than an importer: a subscribed calendar is read-only and redrawn from scratch each refresh, so nothing has to round-trip or survive an edit, which is most of what makes a full iCalendar implementation large. What it does have to do is never mis-state a time -- a DATE is built in local time rather than at UTC midnight, which would land on the day before for anyone west of Greenwich -- and never hang on a document somebody else wrote. Events go through instancesIn like the birthdays, so no view has to know they are not real calendars, and the calendar they hang off reports no write rights, so everything that asks before offering an edit declines on its own. A subscription that cannot be read says so in the sidebar rather than drawing an empty calendar, which looks like a calendar with nothing in it. |
||
|
|
93d0a32af2 |
Serve ihasmail from a subpath
`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.
That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.
Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.
Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.
Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.
Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.
Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.
|
||
|
|
15f2c3d357 |
Read a Markdown file as the document it is
A .md previewed as its own source, which is reading the punctuation rather than the notes. It now opens rendered, with Rendered | Source in the dialog footer for anyone who wants what the file actually says. Markdown only; a .txt has nothing to toggle between. Rendering is `marked`, sanitised by DOMPurify -- the one the app already carries for mail. Markdown is not a safe subset of anything: raw HTML passes through it by design, so a <script> in a file somebody uploaded or shared into the account is a script tag unless something takes it out. Images become links rather than pictures. An image in a Markdown file is either a relative path, which has no base to resolve against here, or a URL somewhere else, which fetches on open and tells that server the file was read -- the tracking pixel this app blocks in mail. The link keeps the alt text and the address, so nothing vanishes silently. Fixes the PDF preview while here, which never worked: securityHeaders put X-Frame-Options: DENY on every response including the blob route, so the iframe showed Chrome's "refused to connect" where the file should have been -- in Files today and in mail attachments long before that. The middleware now leaves a header the route has set, and a PDF served inline says SAMEORIGIN. Nothing else on the server is framable. |
||
|
|
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. |