Commit Graph
14 Commits
Author SHA1 Message Date
jcoffey-dev ca2c71e858 Merge scheduled send
Both branches turn on where Stalwart advertises a capability, so they meet
in the same two files. The mock keeps `urn:stalwart:jmap` out of the
session-level capabilities and hands it out per-account, as a real server
does, while the submission capability it grew for scheduled send lives
per-account beside it; the client keeps both accessors, one asking whether a
capability is advertised anywhere and one reading the object itself.
2026-08-24 22:24:13 -07:00
jcoffey-dev 2f8af1ce94 Look for Stalwart's capability where Stalwart advertises it
Self-service credentials, the About page and Files all keyed off
`urn:stalwart:jmap`, and all three looked for it in the session-level
`capabilities`. Stalwart has never put it there. `Session::new` builds that
list from a fixed set the capability is not part of, in any 0.16.x from
0.16.0 to 0.16.19; it is handed out per-account instead, so it arrives in
`primaryAccounts` and in each account's `accountCapabilities`.

So every real 0.16 server read as pre-0.16. Password changes, 2FA and app
passwords fell back to `POST /api/account/auth`, which 0.16 removed, and
reported that the server offers no self-service credential management. About
named the wrong generation. Files ran the pre-0.16 path, omitting `nodeType`
and listing the tree through get.

Look in all three places, on both sides. Two nearby soft spots go with it: a
transport error while probing the registry no longer downgrades a server to
the legacy path -- which would have posted the current password to an
endpoint that is not there -- and a locale request that is merely refused no
longer discards a generation the capability had already settled.

The mock advertised the capability in the session, which is why no test ever
caught this; it now advertises it where the real server does, and validates
`using` by the urn rather than by the session, as Stalwart does. Put the old
lookup back and nine tests fail.

Stalwart still publishes no version number to clients -- VERSION_PUBLIC is a
fixed "1.0.0" -- so About continues to report the generation and edition,
which are now the right ones.
2026-08-24 22:21:26 -07:00
jcoffey-dev 6127a77458 Hold a message in the server's queue until the time you asked for
Scheduled send, which the README listed as needing server support that
Stalwart has had all along. The delay cannot be asked for directly --
RFC 8621 makes `sendAt` read-only and server-derived -- so it goes on the
envelope as an RFC 4865 `HOLDUNTIL` parameter, and the server reports back
the time it settled on.

Stalwart advertises this in the *account* capability, not the session-level
one (which is empty): `maxDelayedSend` of thirty days and `FUTURERELEASE`
among its `submissionExtensions`. The composer offers scheduling only when
both are there, and never offers a time the server would refuse.

A held message goes to a Scheduled folder rather than Sent, because
`onSuccessUpdateEmail` would otherwise file it as sent the moment the
submission is created, and it has not been sent. Nothing moves it out when
the hold expires, so the folder is reconciled on the way in: released
messages to Sent, cancelled ones back to Drafts. Cancelling uses a separate
`Email/set` rather than `onSuccessUpdateEmail`, whose key Stalwart reads as
an Email id and not, as the RFC says, a submission id.

The mock grows the whole lifecycle, and learns to resolve creation
references while it is there -- it had been quietly declining to create any
submission at all, since sending names its message as `#m`. Because
Stalwart's own `futureRelease` setting defaults to off and then drops the
hold in silence, `npm run dev:mock:no-future-release` reproduces that.

Verified end to end against the mock; not yet against the live server.
2026-08-24 22:07:29 -07:00
jcoffey-dev f1f762c228 Empty a folder in batches the server will accept
Emptying Deleted Items back-referenced one Email/query straight into one
Email/set, so every id in the folder arrived in a single call. Stalwart
refuses the whole call over maxObjectsInSet with requestTooLarge, which
left a folder of 5192 messages impossible to empty at all.

Walk the folder a page at a time instead: the filter re-runs each pass,
so the next page is whatever is still there. A pass that destroys nothing
stops the loop and reports the server's error rather than spinning.

The same defect sat in two neighbours. Delete-forever on a large
selection sent every id in one Email/set, and mark-all-read fed up to
5000 ids into an Email/get that only echoed them back - past
maxObjectsInGet, and for no gain, since the query already returned them.
Both now page through the same ceiling, read from the session rather than
hardcoded.

The mock advertised maxObjectsInSet but never enforced it, so none of
this could fail in a test. It now rejects oversized get and set calls the
way Stalwart does.

While here, restrict emptying to Deleted Items. It was offered on Junk
too, where a permanent one-shot clear is harder to justify; Junk is now
select-all plus Delete, which takes the batched path.
2026-08-24 12:55:23 -07:00
jcoffey-dev 287af22ef8 Connect the image proxy to the address it checked
The proxy resolved a hostname, refused it if any answer pointed somewhere
private, and then handed the *hostname* to fetch — which resolved it again when
the socket opened. An attacker who controls the zone answers with a public
address the first time and 127.0.0.1 the second, and the check has been walked
straight past. It is the standard way an SSRF guard gets bypassed.

Resolve once and connect to that address: a `lookup` that returns what we
already approved, on `node:http`/`node:https` rather than fetch, since fetch
gives no say in how the socket is opened. Every redirect hop is re-checked and
re-pinned. TLS is unaffected — the certificate is still validated against the
hostname, which `servername` and the Host header carry.

Pooling had to go with it: sockets are keyed by host and port, not by the
address we pinned, so a connection opened earlier would be reused and the pin
never consulted. Found by a test, not by reading it back.

Also refuse two ranges the old check let through: IPv6 multicast, and the NAT64
prefix, which is a route into IPv4 space.
2026-08-24 11:27:14 -07:00
jcoffey-dev f1a2972d3a 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 1aee40a169 Teach the mock to impersonate Stalwart 0.15
MOCK_STALWART=0.15 (or npm run mock:legacy) switches the mock to the
generation before the registry. It is not a cut-down mock: it reproduces the
specific ways that generation differs, and every one of them is a thing the
server does not report as an error.

  - urn:stalwart:jmap is not a capability it knows, and naming one it cannot
    parse fails the whole request rather than the one call
  - x: methods do not exist; credentials live at POST /api/account/auth
  - FileNode/query masks out containers, so it returns files and never folders
  - FileNode has no nodeType, and rights are only mayRead/mayWrite/mayShare

Both modes now also enforce the 2047-byte signature cap, and `using` is
validated in both — the gap that let the Identity capability bug in #12 ship.

This gives the legacy credential adapter its first automated coverage: it was
the least-tested code here, checked only by hand against the live server. The
new tests also pin the mock's own fidelity, so it cannot quietly drift back to
being 0.16-shaped in the places that matter.
2026-08-24 10:23:39 -07:00
jcoffey-dev ad0b913efb Fix two things live testing on 0.15.5 turned up
**About said "not detected".** Generation was only worked out from the reply to
a registry method, which we never send to a server that does not advertise
urn:stalwart:jmap — every 0.16 build does, and nothing older knows the
capability at all, so its absence is already the answer. Say so, instead of
shrugging. A session with no capabilities at all stays unknown, which is a
different thing from old.

**The caret jumped out of the OTP field after one digit.** Dialog's autofocus
effect listed onClose in its dependencies, and every caller passes an inline
arrow, so each keystroke in a dialog holding state tore the effect down, set it
up again, and refocused the first field — which in the disable-2FA dialog is
the password. Keep the handler in a ref so the effect depends only on `open`.
This was a bug in the shared dialog rather than in one screen; every dialog
with more than one field had it.

The test for it fails against the old dependency array, not just passes
against the new one.
2026-08-24 09:04:16 -07:00
jcoffey-dev 337c46ebda Treat an unreadable backend probe as the older server, not an error
Stalwart before 0.16 does not know urn:stalwart:jmap, and rejects the whole
request rather than the one call when `using` names a capability it cannot
parse. The probe is only sent when the session advertises that capability, so
this should not arise — but if it ever does, throwing turns a server we can
still manage credentials on into a Security page that only shows an error.
Fall through to the endpoint those servers do have.
2026-08-24 08:41:15 -07:00
jcoffey-dev c55b54163f 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 3621e81d0c 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 be893ef482 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 626a48e678 Folder pane: align icons, mark-read incl. subfolders
- Move the expand chevron into a gutter left of the folder icon so folders
  with and without subfolders line up on their icon; labels share the column.
- Add "Mark all as read, incl. subfolders" to the folder context menu, with
  the affected count and a per-folder fallback for servers without filter
  operators.
- Re-measure the virtualised message list when row height changes.
- Mock: seed unread mail in a subfolder.
2026-08-23 01:34:15 -07:00
jcoffey-dev c17887e48e 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