Compare commits

...
147 Commits
Author SHA1 Message Date
jcoffey-dev 2174ccb7b3 Merge pull request 'Send security and conduct reports to Coffey Labs LLC addresses' (#55) from chore/company-contacts into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m16s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 07:25:37 +00:00
jcoffey-dev 0580812216 Send security and conduct reports to Coffey Labs LLC addresses
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m33s
ci / announce (pull_request) Skipped
Security reports now go to [email protected] and code-of-conduct reports to [email protected], replacing a personal address. Written in the same obfuscated form as before.
2026-10-06 00:22:15 -07:00
jcoffey-dev 06ea28dade Merge pull request 'Name Coffey Labs LLC as the copyright holder' (#54) from chore/copyright-coffey-labs-llc into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m34s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 06:42:03 +00:00
jcoffey-dev 20ac83c8df Name Coffey Labs LLC as the copyright holder
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m5s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Coffey Labs is now Coffey Labs LLC, an Arizona limited liability company. Copyright lines and SPDX-FileCopyrightText headers naming Coffey Labs or John Coffey now name Coffey Labs LLC. Upstream copyright notices are unchanged.
2026-10-05 23:33:07 -07:00
jcoffey-dev a58ea326d9 Merge pull request 'Notifications with the app closed, for every signed-in account' (#53) from feat/other-accounts-push-b into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m24s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 03:42:31 +00:00
jcoffey-dev 9fcf4812f3 Notifications with the app closed, for every signed-in account
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m3s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Second of three for background push across accounts (multi-account
spec, MA-8 part 2).

Turning on "Notify me even when inbuxa is closed" now registers this
browser in every signed-in account, each through its own session (the
route from the previous change), and renewal keeps them all current.
GET /api/auth/accounts says which mail account each session is, so the
subscription can name it. The remembered endpoint is kept per account,
and survives the clean-up that follows switching accounts.

The service worker is told who the other accounts are. A push for one
of them is titled with that account's address, shown even while a tab
is focused (the tab only shows the front account's mail), and its
Mark read and Archive act through that account's session. Clicking it
brings that account to the front and opens the message. While push is
on, the tab's own polling of other accounts stops notifying, so nothing
arrives twice.

Signing out of one account removes this device's subscription there
only; signing out of all, or turning push off, removes every one.

Also fixes where every background notification opened: the worker
linked to /mail/inbox/<thread>, and the route takes a mailbox id there,
so a click landed on the inbox list with "That folder no longer
exists". The worker now gets each account's inbox id and links to the
message.

Checked end to end in Chrome against a local server with two accounts:
both registered and verified, a message to the account not in front
showed a notification under its address, and clicking it switched
accounts and opened the message. No new strings. typecheck, tests
(web 1547, server 279) and build pass.
2026-10-05 20:38:44 -07:00
jcoffey-dev 13c9b8ef91 Merge pull request 'A narrow route to another signed-in account, for its push subscription' (#52) from feat/other-accounts-push into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m23s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 03:27:03 +00:00
jcoffey-dev 6b979c5ac7 A narrow route to another signed-in account, for its push subscription
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m24s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
First of three for notifications with the app closed, for every
signed-in account (multi-account spec, MA-8 part 2). No behavior
changes yet.

A JMAP push subscription belongs to whoever signs the request, so an
account that isn't in front can only have one registered, verified and
renewed through its own session. POST /api/auth/accounts/<sessionId>/jmap
forwards to the mail server as that session, when it is one of this
browser's other accounts, and only for PushSubscription/get and /set,
Mailbox/get, and Email/set limited to keywords and mailboxIds updates
(what a notification's Archive and Mark read need). Anything else is
403; the account in front, or a session this browser doesn't hold, is
404. Its OAuth token is renewed first if due. The browser already holds
the session, so nothing new becomes reachable.

On the web app side the push helpers (list, create, extend, destroy,
verify) take a JMAP caller, defaulting to the account in front exactly
as before, and lib/notify/otherAccount gives the caller for another
account through the route.

Tests: the allowlist, the route end to end with two accounts (allowed,
refused, front and unknown sessions), and the caller. The accounts test
file now raises LOGIN_RATE_LIMIT, since it signs in more often from one
address than the default allows. typecheck, tests (web 1543, server
278) and build pass.
2026-10-05 20:24:18 -07:00
jcoffey-dev 8acd30e8a1 Merge pull request 'New mail in the other signed-in accounts: counts, a dot, and a notification' (#51) from feat/other-accounts-unread into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 3m2s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 03:12:56 +00:00
jcoffey-dev 5f27923ce6 New mail in the other signed-in accounts: counts, a dot, and a notification
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m24s
ci / announce (pull_request) Skipped
With more than one account signed in (#49), mail arriving in one that
isn't in front went unseen until someone switched to it (multi-account
spec, MA-8).

- GET /api/auth/accounts/unread answers the Inbox unread count of each
  account not in front, asked through that account's own session (its
  OAuth token renewed first if due), kept a minute per account.
- The web app asks every two minutes while another account is signed
  in. The account menu shows each one's count beside its name, and the
  avatar carries a dot when any of them has unread mail.
- When a count rises while the app is open and desktop notifications
  are on, a notification names the account ("New mail for
  [email protected]"); clicking it switches to that account. An
  account seen for the first time doesn't notify: its mail was already
  there.

Not in this change: notifications with the app closed, which need each
added account's own Web Push subscription.

New strings (3, English only in the other ten catalogs): "New mail for
{name}", "Unread in the Inbox: {count}", "Account: new mail in another
account". Tests: the server answers the other account's count and
nothing when alone; the client keeps the counts, notifies only on a
rise and only with notifications on. Checked in Chrome against the
mock. typecheck, tests (web 1541, server 277) and build pass.
2026-10-05 20:10:07 -07:00
jcoffey-dev 68c91a1ad4 Merge pull request 'Ten translations: count Turkish where the repo states the number' (#50) from docs/ten-translations into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m15s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 02:33:55 +00:00
jcoffey-dev 9e77fb3f47 Ten translations: count Turkish where the repo states the number
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m30s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Turkish shipped (ihasmail #32/#37), so ten languages ship alongside English, not nine. CONTRIBUTING.md, the PR template and the emlName.ts comment said nine; ROADMAP.md and the i18n-literals.mjs comment describe what shipped on 2026-08-31 and stay as they are. Translations: no user-visible strings added or changed, so no catalog work.
2026-10-05 19:30:40 -07:00
jcoffey-dev 351a5aa01d Merge pull request 'Account switcher: more than one account signed in at once' (#49) from feat/account-switcher into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m25s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 01:47:37 +00:00
jcoffey-dev 34baca4365 Account switcher: more than one account signed in at once
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m26s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Someone who looks after several mailboxes of their own can now keep
them all signed in in one browser and move between them from the
account menu, without signing out (multi-account spec, MA-B; forum
topic 75).

How it holds them. The session cookie is unchanged: it is the account
in front, and every request is answered with it, so nothing else in the
server changes. The others ride in a second cookie, <name>_more, as a
list of their own session cookies. Each session stays its own -- sealed
credential, expiry and "this is my device" -- and nothing about one is
read through another. At most 5 in all, all on this mail server.

- Add account (account menu): with sign-in on the mail server's page
  it goes there with prompt=login, so the server asks again rather than
  reuse the first sign-in; with the password form, a small dialog asks.
  Refused, and the front stays, when either account's organization has
  addAccounts off (inbuxa:SharingPolicy), the account is on another
  server, or 5 are open. The same account again just comes to the
  front.
- Switching (POST /api/auth/accounts/<id>/front) swaps it into front;
  the web app clears what it cached for the previous account and
  reloads. A message being written blocks the switch.
- Sign out ends only the account in front, and the next one comes
  forward; Sign out of all accounts ends every one.
- GET /api/auth/accounts lists them, the front first, and says whether
  one more may be added.

Not in this change: unread counts and notifications for the accounts
not in front (MA-8), which the spec puts last.

The mock can sign in a second user (MOCK_SECOND_USER/PASS) and answer
addAccounts false (MOCK_NO_ADD_ACCOUNTS), for the new server tests:
two accounts joining, switching, the same account twice, a switch to a
session it doesn't hold, signing out of one and of all, and an
organization that forbids it; and the OAuth start asking prompt=login.
The client tests cover listing, switching (cache cleared, reload) and
both sign-outs. Checked in Chrome against the mock: add, switch, sign
out of one.

New strings (9, English only in the other ten catalogs): "Add
account", "Sign out of all accounts", "Add an account", "Both accounts
stay signed in here; switch between them from this menu.", "Working…",
"That account couldn't be added.", "You can't add more accounts here.",
and the other-server and organization refusals. typecheck, tests (web
1538, server 276) and build pass.
2026-10-05 18:44:45 -07:00
jcoffey-dev 9f53759462 Merge pull request 'Show an assigned shared mailbox as shared, not as a locked account' (#48) from feat/shared-mailbox-kind into main
ci / version (push) Skipped
ci / github (push) Skipped
github/ci (branch) GitHub Actions
ci / node (push) Successful in 2m45s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 23s
2026-10-05 22:18:41 +00:00
jcoffey-dev c66a8aadd7 Show an assigned shared mailbox as shared, not as a locked account
ci / version (pull_request) Skipped
ci / github (pull_request) Skipped
ci / node (pull_request) Successful in 1m29s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 36s
github/ci (branch) GitHub Actions
The server now marks a shared mailbox (support@, legal@) as a
delegation of kind "sharedMailbox" (multi-account spec, MA-S). Without
this, the webmail would show one exactly as it shows a locked account:
a red bar, a padlock in the tab and on the brand, and its calendars and
files in place of the reader's own.

Now such a mailbox is listed with the other shared mailboxes under
"Mail to show", opens with the shared bar, which also gives the
person's access level and whether they can send as it, and changes
mail only. Its access level still applies exactly as a lock's does:
read changes nothing, organize never deletes, no sending without
send-as. Found without asking Mailbox/get, since the server's mark says
it is mail.

A server that sends no kind is treated as before: every delegation is a
lock. No new strings. typecheck and vitest (174 files, 1534 tests)
pass.
2026-10-05 15:15:57 -07:00
jcoffey-dev 7f12a7d8ee Merge pull request 'Open a group's mailbox, or folders someone shared, from the account menu' (#47) from feat/shared-mail into main
ci / version (push) Skipped
ci / github (push) Skipped
github/ci (branch) GitHub Actions
ci / node (push) Successful in 3m1s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 16s
2026-10-05 21:52:10 +00:00
jcoffey-dev 722a68432c Open a group's mailbox, or folders someone shared, from the account menu
ci / version (pull_request) Skipped
ci / github (pull_request) Skipped
ci / node (pull_request) Successful in 1m29s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 41s
github/ci (branch) GitHub Actions
A group's members, and anyone a folder was shared with, could reach that
mail over IMAP but not here: Mail read only the reader's own account.
Calendar, Contacts and Files already list other people's shares; Mail
now does too (multi-account spec, MA-A).

Such an account is listed under "Mail to show" in the account menu,
after any locked account handed to the reader, and opens in place of
the reader's own mail through the same switch AL-7 uses. Only accounts
whose Mailbox/get answers with a mailbox are offered: the server
advertises every capability on any shared account, so a colleague who
shared one calendar would otherwise appear with mail.

While one is in view:

- a bar in the palette's accent (not the locked account's red) names
  it, with "Back to my mail"; the tab title names it without a padlock;
- calendars, contacts and files stay the reader's own (viewAccountFor
  follows only a delegation now);
- writing a message uses the viewed account's identities, so a reply in
  support@ goes out as support@ and is saved in its Drafts and Sent.

Losing the account (removed from the group, share withdrawn) takes the
reader back to their own mail with the existing "You no longer have
access" notice.

New string: "Shared mailbox:" (1, English only in the other ten
catalogs). Checked in Chrome against a scratch server with a support@
group and two members. typecheck and vitest (174 files, 1533 tests)
pass.
2026-10-05 14:49:28 -07:00
jcoffey-dev e94508ea89 Merge pull request 'App rail, pill counts, tinted folder icons and raised controls' (#46) from feat/app-rail-layout into main
github/ci (branch) GitHub Actions
github/ci (tag inbuxa-v2026.10.5-ge94508e) GitHub Actions
ci / github (push) Skipped
ci / version (push) Successful in 25s
ci / node (push) Successful in 2m46s
ci / docker-build (push) Skipped
ci / publish (push) Successful in 3m33s
ci / announce (push) Successful in 35s
2026-10-05 19:15:50 +00:00
jcoffey-dev 6190d2b4f5 Merge remote-tracking branch 'origin/main' into feat/app-rail-layout
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m44s
ci / announce (pull_request) Skipped
2026-10-05 12:12:17 -07:00
jcoffey-dev ed23610e31 Merge pull request 'Turkish: the 88 strings that fell back to English' (#43) from i18n/turkish-inbuxa-strings into main
ci / node (push) Skipped
ci / docker-build (push) Skipped
ci / version (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 3m4s
ci / announce (push) Skipped
2026-10-05 19:12:03 +00:00
jcoffey-dev 3e044057f6 Merge pull request 'Composer: smarter suggestions, links over selections, big images attached' (#44) from feat/composer-polish into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Canceled after 0s
ci / github (push) Canceled after 6s
2026-10-05 19:12:00 +00:00
jcoffey-dev 69ccb313e3 Merge pull request 'Save the people you write to as contacts' (#45) from feat/collect-recipients into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / announce (push) Canceled after 0s
ci / github (push) Canceled after 0s
ci / publish (push) Skipped
ci / docker-build (push) Skipped
2026-10-05 19:11:57 +00:00
jcoffey-dev 29795f3988 Merge remote-tracking branch 'origin/main' into feat/collect-recipients
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m46s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
# Conflicts:
#	web/src/store/compose.ts
2026-10-05 12:04:39 -07:00
jcoffey-dev 38dcbd9e0c Merge pull request 'Never send a message twice after a lost reply' (#42) from fix/send-no-duplicates into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 3m4s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-05 19:03:59 +00:00
jcoffey-dev 5fe929c6a4 App rail, pill counts, tinted folder icons and raised controls
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m4s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
On a wide screen the switcher moves from the foot of the folder pane to a
rail down the left edge: Mail (with the inbox's unread badge), Calendar,
Contacts and Files, then Settings and the folder-list toggle at the
bottom. The top bar loses the menu button and the Settings gear there,
both now on the rail. Phones are unchanged: no rail, the tab bar, the
menu button for the drawer and the gear.

- Folder counts are pills: filled with the accent when there is unread
  mail, neutral for totals (Drafts, Scheduled).
- Role folders get their own icon tint (Inbox, Drafts, Sent, Archive,
  Junk, Trash, Scheduled); a color the reader picked still wins.
- Buttons, Compose and the current selection have a top-lit, raised look
  with a pressed state; Compose and the selected rail item are filled
  with the accent.
- Collapsing the sidebar keeps Mail's icon strip; Calendar, Contacts,
  Files, Settings and Admin have no icon-only form, so their sidebar
  hides instead of being crushed.
- The collapsed sidebar no longer draws a cut-off "FOLDERS": the rule
  hiding section headings lost to a later one of the same weight (this
  is on main today too).

Contrast: the accent behind small text is darkened in light themes and
lightened a touch in dark ones. Measured in a browser across all 12
palettes x 6 accents x both modes: text on pills, the rail badge and
Compose at least 4.83:1, tinted icons at least 3.14:1 on the sidebar.

New strings: 2 ("Show folder list", "Hide folder list"), in all ten
catalogs (1699 -> 1701).
2026-10-05 12:01:27 -07:00
jcoffey-dev c2502d36bd Save the people you write to as contacts
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m5s
ci / announce (pull_request) Skipped
The addresses written to were remembered only in the browser that sent
the message, as a list of recent recipients, so a new device or a cleared
browser suggested nobody. After each confirmed send, the recipients who
are not contacts yet are now saved on the server, in an address book of
their own called Collected, so they are suggested everywhere.

- Only addresses on no card in any address book, own or shared, are
  added, de-duplicated, and never the sender's own identities.
- The book is created on first use and remembered by id in the synced
  settings, so its name can be anything; a deleted one is replaced.
- Names are split as the contact editor does ("Smith, Jane").
- Settings > Calendar & contacts > Contacts has a switch, on by default,
  as mail clients do; the book can be emptied or deleted like any other.

New strings: 3, in all ten catalogs (1699 -> 1702).
2026-10-05 11:55:51 -07:00
jcoffey-dev b11f3997d4 Composer: smarter suggestions, links over selections, big images attached
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m5s
ci / announce (pull_request) Skipped
Recipient suggestions:
- the words typed match in any order, each one the start of a word in the
  name, a nickname, the organization or the address: "jane smi" finds
  "Smith, Jane", and "globex" finds the people at Globex;
- someone written to lately ranks a little above an equal match;
- an address already in To, Cc or Bcc is no longer offered in the other
  two fields.

Pasting:
- a single web or mailto address pasted over selected words makes those
  words the link, instead of replacing them with the address;
- a pasted or dropped image over 10 MB goes in as an attachment rather
  than inline, where it would swell every reply.

No new strings.
2026-10-05 11:52:54 -07:00
jcoffey-dev f6320e6614 Turkish: the 88 strings that fell back to English
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m24s
ci / announce (pull_request) Skipped
Turkish came from public ihasmail (#41), which has no strings for what
only this webmail has: delegated and locked accounts, sign-in through
the mail server, the suite's About page, legacy protocol switches, the
data loss prevention notices, and held-for-review sends. Those 88 showed
in English.

They are translated now in the terms the Turkish catalog already uses
(Hesap, Yönetim, Kiracı, posta sunucusu, posta uygulaması), and the 22
Turkish entries no key looks up any more (ihasmail's Stalwart wording,
renamed here) are removed. Turkish: 1611/1699 -> 1699/1699, no stale
keys. These 88 are not a native speaker's; the rest of the catalog is.
2026-10-05 11:47:28 -07:00
jcoffey-dev 152311b035 Never send a message twice after a lost reply
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 1m44s
ci / announce (pull_request) Skipped
When a send's request went out and no answer came back, the composer said
"Send failed" and offered the draft back. If the server had in fact
accepted it and only the reply was lost, sending the draft again
delivered the message twice. Nothing identified the first attempt, so
nothing could check.

Each send now carries its own Message-ID (on the sending identity's
domain, RFC 5322 §3.6.4), kept on the draft if it comes back. A failure
that may have happened after the server acted (no answer, a timeout, a
5xx from the proxy) is followed by asking the server what it did:

- a message with that Message-ID was submitted (EmailSubmission/query by
  emailIds, RFC 8621 §7.3): it was sent, and the composer says so;
- one exists with no submission: it was created and never sent; it is
  removed from Sent and the failure stands, so resending is safe;
- none exists: the failure stands;
- the server can't be asked either: the composer says it couldn't confirm
  and to check Sent, instead of a plain "Send failed".

A refusal (4xx) means the server did not run the request, so it is not
checked. Sending a draft again that came back from a failure asks first,
and sends nothing if a message with its Message-ID already exists;
submissions are expunged after the server's hold period, so later on any
surviving copy counts as sent (every failure path removes the copy it
made).

New strings: 3, in all ten catalogs (1699 -> 1702, no new fallbacks).
2026-10-05 11:43:55 -07:00
jcoffey-dev 3434a5ed39 Merge pull request 'Fixes and Turkish from public ihasmail (#29–#36)' (#41) from sync/ihasmail-2026-10-05 into main
github/ci (branch) GitHub Actions
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (tag inbuxa-v2026.10.5-g3434a5e) GitHub Actions
ci / github (push) Successful in 3m44s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
announce / announce (release) Successful in 25s
ci / announce (push) Successful in 28s
2026-10-05 17:43:08 +00:00
jcoffey-dev 64b5db01f3 Keep a narrow list's labels inside the row, on the sender's line (ihasmail #36)
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m25s
ci / announce (pull_request) Skipped
On a phone or a narrow list, rows are two lines, and labels went on a third
line of their own. A row is a fixed height at each density, and that third
line fit it only at Comfortable: at Cozy, the default, the labels were cut
off at the bottom, and at Compact they sat outside the row.

They now sit on the sender's line, after the name, which has room to spare,
so the row's height is unchanged at every density. When the line runs short
the name gives way first, then each label truncates; the date stays.

Reported in coffey-labs/ihasmail#35.

(cherry picked from commit 22490d8fe0190d85d8039a977435245b0d7da3cc)
2026-10-05 10:35:26 -07:00
jcoffey-dev af9b3a7f70 Indent the mark-read effect's first two lines again
Lost while resolving the conflict in the #30 pick.
2026-10-05 10:33:48 -07:00
jcoffey-dev 8a670c5e70 Turkish: the three image size strings (ihasmail #34)
(cherry picked from commit 5987f9c815d13df133841c4873b2aa9c394bc433)
2026-10-05 10:33:41 -07:00
jcoffey-dev cfb28ada45 Let mail wider than the pane scroll sideways (ihasmail #31)
(cherry picked from commit 14680d0e1b2cc966aa840a45e73b238322ed5e17)
2026-10-05 10:33:41 -07:00
jcoffey-dev 7b9da44116 Keep an open message unread after Mark as unread (ihasmail #30)
(cherry picked from commit 4a6bf677cbed9ecd23d02c0788c927e0dc9e941a)
2026-10-05 10:33:41 -07:00
jcoffey-dev 300ec1df99 Resize images in the composer (ihasmail #33)
(cherry picked from commit f17bdc9fff1455a734e19fdd8004fcd6b7970825)
2026-10-05 10:33:31 -07:00
jcoffey-dev 3f19f82960 Add Turkish translation (contributed by Hakan Arslan) (ihasmail #32)
(cherry picked from commit 017b1b5eb812ba18c3577ff79c544a25f7f91aaa)
2026-10-05 10:33:27 -07:00
jcoffey-dev 2e7f30c64e Finish the settings load when the tree remounts mid-load (ihasmail #29)
(cherry picked from commit 5ec06f40125cc320fba64edaa4ee1a1fb23d22d2)
2026-10-05 10:33:08 -07:00
jcoffey-dev c558693db5 Merge pull request 'ci: let the announce workflow run by hand for a given tag' (#40) from ci/announce-dispatch into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m25s
ci / announce (push) Skipped
2026-10-05 03:56:29 +00:00
jcoffey-dev c87a6d1cd6 ci: let the announce workflow run by hand for a given tag
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m7s
ci / announce (pull_request) Skipped
A release event runs the workflow as it was at the tag, so re-running a
failed announcement repeats the failure even after main is fixed. A manual
run takes the tag and uses main's workflow; the action already accepts it.
2026-10-04 20:53:46 -07:00
jcoffey-dev 34d3f21b4b Merge pull request 'ci: pin discourse-release at 9282c6f, which maps inbuxa-webmail' (#39) from ci/announce-pin-inbuxa-webmail into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 1m55s
ci / announce (push) Skipped
2026-10-05 03:49:11 +00:00
jcoffey-dev 0c5ad7a3b8 ci: pin discourse-release at 9282c6f, which maps inbuxa-webmail
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m26s
ci / announce (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / node (pull_request) Skipped
The release map knew this repo only by its old name, so announcing
inbuxa-v2026.10.5-g17a8093 failed. The action itself is unchanged.
2026-10-04 20:46:27 -07:00
jcoffey-dev 17a8093c09 Merge pull request 'Rename the repository to inbuxa-webmail' (#38) from chore/rename-to-inbuxa-webmail into main
github/ci (branch) GitHub Actions
github/ci (tag inbuxa-v2026.10.5-g17a8093) GitHub Actions
ci / github (push) Successful in 3m47s
announce / announce (release) Failing after 24s
ci / node (push) Skipped
ci / docker-build (push) Skipped
ci / version (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Failing after 22s
2026-10-05 03:31:51 +00:00
jcoffey-dev 5664896f53 Rename the repository to inbuxa-webmail
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 1m30s
ci / announce (pull_request) Skipped
The repository moved from inbuxa/ihasmail-inbuxa to inbuxa/inbuxa-webmail,
matching inbuxa-server and inbuxa-admin. Point the source links, the image
name and the package link at the new name. The OAuth client id stays
ihasmail-inbuxa, since that is what the server registers.
2026-10-04 20:29:09 -07:00
jcoffey-dev 4d7668601e Merge pull request 'ci: copy each release image to GHCR as a replica' (#37) from ci/ghcr-replica into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m34s
ci / announce (push) Skipped
2026-09-30 16:30:40 +00:00
jcoffey-dev bdee5de814 ci: copy each release image to GHCR as a replica
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m23s
ci / announce (pull_request) Skipped
The Gitea registry stays authoritative; GHCR becomes a copy of it, the way
the GitHub repository is a copy of the Gitea one. After the tag build has
pushed the release image to the registry, a new ghcr job copies it to
ghcr.io under the same version tag and :latest with `imagetools create` --
a copy, not a rebuild, so the digest on GHCR is the digest on the registry.

Anything still pulling the old ghcr.io name, including the TrueNAS app
submission, keeps receiving releases. The job uses the run's own token and is
left out of the status reported to Gitea, so a GHCR problem cannot fail a
release.
2026-09-30 09:27:48 -07:00
jcoffey-dev b1436d4d2a Merge pull request 'ci: copy each release to GitHub after the tag build' (#36) from ci/github-release-copy into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m16s
ci / announce (push) Skipped
2026-09-30 13:54:41 +00:00
jcoffey-dev a7c7d88408 ci: copy each release to GitHub after the tag build
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m29s
ci / announce (pull_request) Skipped
The mirror carries tags to GitHub but not releases, so the replica's
Releases page -- and anyone watching the repository there -- stopped at the
last release made on GitHub. After the tag build has published, a new
github-release job copies the tag's Gitea release to a GitHub release: the
same notes, with PR and issue numbers rewritten to Gitea links, the same
files, and a line pointing back to the Gitea release.

It uses the run's own token and is left out of the status reported to
Gitea, so it cannot fail a release. With no Gitea release for the tag it
does nothing.
2026-09-30 06:52:03 -07:00
jcoffey-dev 63ddc23ee2 Merge pull request 'ci: name the tag in GitHub's tag status, and make digests re-runnable' (#35) from ci/tag-path-hardening into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m24s
ci / announce (push) Skipped
2026-09-30 13:47:42 +00:00
jcoffey-dev ae83d27e4b ci: name the tag in GitHub's tag status, and make digests re-runnable
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m29s
ci / announce (pull_request) Skipped
Commit statuses belong to the commit, not the tag. Upstream v* tags and
inbuxa-v* tags can sit on the same commit, so Gitea's github job, waiting
for "github/ci (tag)", could read the other tag's older result and move on
before this tag's build finished. A tag's status is now "github/ci (tag
<name>)" on both sides.

The per-architecture digest artifacts are also overwritable and kept for a
week, so re-running a build, or publish on its own, still works.
2026-09-30 06:44:52 -07:00
jcoffey-dev 043fe4ca54 Merge pull request 'ci: run the github wait job on its own runner label' (#34) from ci/wait-runner into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m13s
ci / announce (push) Skipped
2026-09-30 07:39:03 +00:00
jcoffey-dev 24dccdb47d ci: run the github wait job on its own runner label
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m38s
ci / announce (pull_request) Skipped
The github job only polls Gitea for GitHub's commit status, but it holds a
runner slot for as long as the GitHub build takes -- the better part of an
hour for a cold build. On the shared build runners a handful of those
could take every slot and stall real work, so it now runs on the `wait`
label: a runner of its own, with many slots, no docker socket and a small
CPU and memory cap.
2026-09-30 00:36:13 -07:00
jcoffey-dev 6e1166319a Merge pull request 'ci: a cancelled GitHub run no longer reports failure to Gitea' (#33) from fix/ci-report-cancelled into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m22s
ci / announce (push) Skipped
2026-09-30 07:29:52 +00:00
jcoffey-dev ddb6ce4611 ci: a cancelled GitHub run no longer reports failure to Gitea
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m30s
ci / announce (pull_request) Skipped
The mirror can push one commit twice in quick succession. GitHub then
starts two runs and cancels the older, and that run's report job posted
"failure" for the commit. Gitea's github job, seeing the newest status,
failed the check while the surviving run was still building and later
passed.

A cancelled run now posts nothing and leaves the result to the run that
superseded it. A real failure still reports failure.
2026-09-30 00:26:40 -07:00
jcoffey-dev bf35e75f88 Merge pull request 'docs: point issues and discussions at Gitea and the forum' (#32) from docs/mirror-note into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m28s
ci / announce (push) Skipped
2026-09-30 07:18:31 +00:00
jcoffey-dev 3251478d61 docs: point issues and discussions at Gitea and the forum
ci / node (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / version (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 1m49s
ci / announce (pull_request) Skipped
This repository is now push-mirrored to GitHub, where issues and pull
requests would never reach the maintainers. A note under the title says
where development happens, and sends issues to git.coffeylabs.org and
discussions to community.coffeylabs.org.
2026-09-30 00:16:10 -07:00
jcoffey-dev 401c32a3e6 Merge pull request 'ci: build on GitHub via the mirror, switchable with BUILD_ON' (#31) from ci/build-on-github into main
ci / version (push) Skipped
ci / github (push) Skipped
ci / node (push) Successful in 2m58s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 39s
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m4s
ci / announce (pull_request) Skipped
Reviewed-on: inbuxa/ihasmail-inbuxa#31
2026-09-30 06:52:38 +00:00
jcoffey-dev 216ebd4dfe Build on GitHub Actions when BUILD_ON=github
ci / docker-build (pull_request) Successful in 1m20s
ci / github (pull_request) Skipped
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m23s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Gitea stays the source of truth and push-mirrors this repository to
GitHub. The org variable BUILD_ON, set on both forges, picks where the
heavy work runs:

- unset: nothing changes. Gitea's jobs run as before and every job in
  the GitHub workflow is skipped.
- github: Gitea skips its test, build and publish jobs. GitHub Actions
  runs them on hosted runners, arm64 natively rather than under QEMU,
  publishes to the same Gitea registry, and posts a commit status back
  to Gitea. A new `github` job in Gitea's ci.yml waits for that status
  and passes or fails with it, so the Gitea run still decides a PR.

Announcing and releasing stay on Gitea whatever BUILD_ON says.

The GitHub-era workflows go: cleanup.yml pruned GHCR, release.yml was a
second weekly scheduler, and publish.yml pushed to GHCR. Their work is
in the new .github/workflows/ci.yml or stays on Gitea. dependabot.yml
goes too: its pull request branches would exist only on GitHub, and
every mirror sync would delete them.
2026-09-29 23:06:27 -07:00
jcoffey-dev 829e46beae Merge pull request 'Confirm a typed password without replaying it over JMAP' (#30) from fix/confirm-password-without-basic into main
ci / version (push) Skipped
ci / node (push) Successful in 2m32s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 2m20s
2026-09-29 17:03:54 +00:00
jcoffey-dev 1acf2f29e7 Confirm a typed password without replaying it over JMAP
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m5s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 35s
Creating an app password asks for the account password. A session
holding a token has no password to compare with, so it sent the typed
one to the mail server as HTTP Basic on the JMAP session. INBUXA's
server now takes no password outside DAV (contract C-23), so that check
would always fail.

It now asks the server's sign-in endpoint, the one its own sign-in page
posts to, as this client, to its registered redirect URI, with a PKCE
challenge whose verifier is thrown away so the code can never be
exchanged. "Two-factor code needed" counts as confirmed: the server
says so only after the password matched, so accounts with two-factor
sign-in now pass where the Basic check failed them.

The mock answers /api/auth like the server and can refuse Basic on
JMAP; the app-password test turns that on, and fails on the old check.
2026-09-29 06:50:32 -07:00
jcoffey-dev d73f5e8f9e Merge pull request 'DLP on send: warnings, blocks and held mail in the composer' (#29) from feature/dlp-dialogs into main
ci / version (push) Skipped
ci / node (push) Successful in 3m17s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 19s
2026-09-29 01:55:54 +00:00
jcoffey-dev 07cfe1310e DLP on send: warnings, blocks and held mail in the composer
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m13s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m13s
The webmail half of inbuxa's DLP (dlp-and-mail-flow-rules spec, §2.5,
§4):

- A send the server's DLP rules refuse (inbuxa:dlpWarning or
  inbuxa:dlpBlocked) comes back to the composer with the rules' notices
  instead of a generic "Send failed". A warning offers "Send anyway…",
  which asks for a reason and sends again with inbuxa:dlpOverride; the
  server records the reason. A block can only be answered by changing
  the message.
- A message DLP held for review says so on sending ("Held for review:
  it's sent once a reviewer releases it"), from the submission's
  inbuxa:held.
- Tests: the override travels with the submission only when there's a
  reason; refusals are told apart from other errors.

Nine new English strings (the notice labels, the prompt, the toasts);
the other catalogs fall back to English until translated.
2026-09-28 18:51:45 -07:00
jcoffey-dev 290bc63dbb Merge pull request 'Say which legacy protocols are off when only some are' (#28) from feature/per-protocol-legacy-note into main
ci / version (push) Skipped
ci / node (push) Successful in 1m19s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 16s
2026-09-28 06:27:12 +00:00
jcoffey-dev 44f8e30c45 Say which legacy protocols are off when only some are
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m10s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 46s
inbuxa can now switch IMAP, POP3 and ManageSieve off one at a time,
server-wide and per organization, and the session lists what is still
allowed for the account (legacyAllowed). Where the webmail said
"legacy protocols are off", it now also covers the case where only
some are:

- Security & sessions, above app passwords: "Your organization has
  turned off POP3 for mail apps. Mail apps that use it can't connect
  to this account; others still can."
- The Administration dashboard: "Some legacy mail protocols are off for
  your organization: POP3."
- An organization's sheet in Administration: its switch stays the
  all-or-nothing one; when only some are off it names them and points
  to the console, where they're switched one at a time, and "Turn
  legacy protocols back on" turns them all back on.

With every protocol off, the existing wording shows, as before. From a
server that doesn't send legacyAllowed nothing new appears.

3 new strings in all nine catalogs, unreviewed.

Tested: unit tests for reading the session and a tenant's switches;
the existing tests updated for the new field; typecheck; the whole
suite (1475 tests); and in headless Chrome against a local server with
POP3 off, where Security & sessions showed the new note.
2026-09-27 23:21:21 -07:00
jcoffey-dev ac0f8789f6 Merge pull request 'Delete people in the console, not the webmail's Administration' (#27) from feature/delete-in-console into main
ci / node (push) Successful in 1m42s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Successful in 27s
2026-09-28 02:34:53 +00:00
jcoffey-dev 5c08fb9fe9 Delete people in the console, not the webmail's Administration
ci / version (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 34s
ci / node (pull_request) Successful in 1m8s
ci / announce (pull_request) Skipped
Deleting a person's account is the console's now, beside locking it and
legal holds: the console asks why, for the audit log, and says when a
hold keeps the data. Where Delete was, the account's page says so and
links to the account in the console when the server names one. Groups,
lists, domains and tenants keep their delete here.

2 new strings in all nine catalogs, unreviewed.
2026-09-27 19:32:33 -07:00
jcoffey-dev 9e47437ef8 Merge pull request 'Open a locked account whole: calendar, contacts and files too' (#26) from feature/delegate-whole-account into main
ci / version (push) Skipped
ci / node (push) Successful in 1m7s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 15s
2026-09-28 01:06:55 +00:00
jcoffey-dev 173680cc41 Open a locked account whole: calendar, contacts and files too
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m7s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 35s
Switching to a locked account handed to the reader moved only the mail.
Now calendar, contacts and files follow it as well, through a new
viewAccountFor that the three stores use for what they show. Settings,
signatures and push keep ownAccountFor, so nothing of the reader's is
ever written into the locked account (inbuxa AL-7).
2026-09-27 18:04:38 -07:00
jcoffey-dev 22d891b1ed Merge pull request 'Seven app fixes from public ihasmail' (#25) from merge/public-app-fixes into main
ci / version (push) Skipped
ci / node (push) Successful in 2m30s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 1m56s
2026-09-27 23:32:21 +00:00
jcoffey-dev c64a23f9d9 List folders in sidebar order in the move-to picker
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m29s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m23s
The picker sorted folders A-Z by path, with Inbox first, so a folder
dragged into place in the sidebar turned up somewhere else when moving
mail. It now walks the tree in compareFolders order, the sidebar's
order with every folder expanded: Inbox, then the saved order, then
the special folders, then A-Z, with subfolders under their parent.

treeOrder lives beside compareFolders. A folder the walk from the top
cannot reach is appended rather than dropped, so it stays pickable as
it was before.

Closes #1

(cherry picked from commit ea03406646062359f74e16ad8a8aed074b4dc409)
2026-09-27 16:27:24 -07:00
jcoffey fedc34698e The toolbar above an open message acts on that message (#414) (#417)
With conversation view off, marking a message unread from the list --
the hover button, the right-click menu -- marked that message. Opening
it and pressing Mark as unread in the toolbar above it marked every
message in its thread, and so did Move to, Report spam and Delete.

The setting already reaches all the way into the reading pane: the list
draws one row per message, and `visibleMessages` narrows the pane to the
one opened. The toolbar was half converted. Its labels were right --
Mark as unread against Mark as read, the star, the labels shown -- all
of those read `messages`, which is the narrowed set. Only `rowIds`, the
one thing actually handed to the action, still read `thread.emailIds`.
So the button said one message and did the whole conversation.

`rowIds` is now the same question `visibleMessages` answers for the
pane, asked of the same ids, with the same fallback: an id that names
nothing in the thread -- a link from somebody with conversation view on,
a stale `m` in the URL -- shows the conversation, so the toolbar takes
the conversation. Conversation view on is unchanged: nothing is singled
out, so the whole thread comes back as before.

No new strings.

(cherry picked from commit f627bfc1237d6e8bbc728147022f624c3834d648)
2026-09-27 16:27:24 -07:00
jcoffey 8bfc7a85a9 A reply to a self-addressed message follows its Reply-To (#415) (#416)
A website contact form mails the site's own address: From and To are
both info@thesite, and the person who filled the form in is in Reply-To.
Replying addressed the draft to info@thesite -- the site's own desk --
instead of to them.

The reply already knows two shapes. A message somebody sent me is
answered to its Reply-To, which is what that header is for. A message
*I* sent is answered to the people I wrote to, and deliberately not to
my own Reply-To, which is where answers to me belong and would send my
reply to myself. A contact form passes the test for the second: every
address in From is mine.

So it fell down the chain the second shape keeps for a message with
nobody obvious to answer -- To without me, then Cc, then, having run
out, every address on the message, which here was mine alone.

The Reply-To now goes in that chain, one step before the last: when no
recipient but me is left and the message names a Reply-To that is not
mine either, that address is who it is really from. Keeping it after the
Cc is what leaves a message I did send alone -- somebody I actually
wrote to still beats my own Reply-To, which is the case the existing
guard was built for and its test still holds.

No new strings.

(cherry picked from commit 01dc322aebcff0e8075c54f81eb7d171a32fb9e7)
2026-09-27 16:27:24 -07:00
jcoffey 2fffc9043d Quote images through the proxy, and unproxy them on the way out (#412) (#413)
Reading a message fetches its remote images through this server, so the
sender learns nothing about the reader. Quoting the same message into a
reply fetched them directly: same pixel, same reader, but the request
carried their IP and user agent -- exactly what the proxy withholds.

A quote now proxies them the way the message view does. That alone would
be wrong, because a proxied URL belongs to this deployment: sent
unchanged it would reach the recipient as images only this server can
serve, broken for them and a beacon back here. So buildEmailObject turns
them back into the addresses they came from, beside the pass that
restores images blocked under pr411 and the one that turns editor blob
URLs into cid: references.

Deployments with the proxy off are unaffected: the quote fetches
directly, as reading does there.

Three tests from pr411 asserted the address sat in src when images were
allowed, which was the old behaviour; they now ask whether the draft
fetches it at all, proxied or not.

No new strings.

(cherry picked from commit 23557a72a2a72088081792f8ea0cabedea4e7bcb)
2026-09-27 16:27:24 -07:00
jcoffey a94fd9cce3 Quoting follows the message's own image decision (#410) (#411)
Replying sanitized the quoted body with allowRemote: true, so quoting
fetched every remote image in the message whatever the reader had
decided about it. A tracking pixel in the quote then reported the
message read, and the address live, to whoever was counting -- the thing
leaving the images blocked was meant to prevent. Edit as new and opening
a draft that quotes a message did the same.

The decision now lives in one place, remoteImagesAllowed(), asked with
the same inputs the reader's answer used: the image policy, the trusted
senders, whether the sender is a contact, and whether Show images was
pressed on that message. The last of those was component state, so it
moves to the mail store, where the composer can see it.

Blocked images already keep their address in data-ihm-remote, so nothing
is lost by not fetching: it goes back on the way out, and the sent quote
is what its sender wrote. The recipient's client decides for itself, as
it would with any other client's reply.

Before pr408 this needed a rich-text default to reach; the format offer
made it reachable from plain text, which is how it was found.

No new strings.

(cherry picked from commit d329b33912921a851c548bffef5085e5fbf72bed)
2026-09-27 16:27:24 -07:00
jcoffey 9ba2c6e290 Switching format keeps the original quote, not a flattened copy (#409) (#409)
Switching a reply between plain text and rich text converted whatever
body the draft was showing. Going from plain text to rich, that meant
the quoted message came back as the "> " text quote run through a
converter -- the sender's formatting, images and links gone, even though
the original markup was sitting on the draft untouched.

Both forms of the quote are prepared when the reply opens, so keep them
on the draft and re-attach the right one when the format changes. Only
what the author typed above the quote is converted. Where the quote
can't be found any more -- edited by hand, or a draft that quotes
nothing -- the whole body is converted as before, which is what every
non-reply draft does.

No new strings.

(cherry picked from commit 88f9e6c50a04f8ffc4702d1d1e3cffa6a93e7690)
2026-09-27 16:27:24 -07:00
jcoffey 996aa66ef0 Offer the message's own format when replying (#407) (#408)
A reply opened in the format the settings ask for, whatever the message
being answered was written in, and the per-draft switch was buried in
the composer's ⋮ menu. Replying in plain text to a rich text message
throws away the formatting; replying in rich text to a plain-text one
overrides what the sender chose to write in.

When the two disagree the composer now says so above the editor -- "This
message is rich text", with a Switch button and a dismiss -- and the
draft still opens in the format the settings ask for. Switching converts
that draft only and leaves the setting alone; switching from the ⋮ menu
answers the offer too. Forwards get it as well, where the formatting
being passed on is somebody else's.

What counts as rich text is hasHtmlAlternative(), which reads the body
part's own type: `htmlBody` is derived (RFC 8621 4.1.4), so a plain-text
message has one too and its presence proves nothing.

The mock said otherwise -- it returned an empty `htmlBody` for a
plain-text message, where Stalwart 0.16.21 returns the text/plain part
in both lists. Both builders now answer as the server does, so the path
this feature depends on is exercised in development rather than only
against a real mailbox.

Two new strings, translated in all nine catalogs; the buttons reuse the
menu's existing "Switch to plain text" / "Switch to rich text". The
count falling back to English stays at 16 in every language.

Fixes #407

(cherry picked from commit d992442b8194be5e9c48204332c7243d9587b4ca)
2026-09-27 16:27:24 -07:00
jcoffey-dev d5c49c2962 Merge pull request 'Mark no interface language Beta' (#24) from i18n/no-beta-languages into main
ci / version (push) Skipped
ci / node (push) Successful in 2m33s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 15s
2026-09-27 23:05:17 +00:00
jcoffey-dev ee675004f2 Mark no interface language Beta
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m26s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 32s
Every shipped language is offered without the Beta mark. The flag and
its settings note stay, so public ihasmail's changes to them still apply.
2026-09-27 16:01:58 -07:00
jcoffey-dev 2361caf2c7 Merge pull request 'Dutch out of Beta: the native speaker's final review, from public ihasmail' (#23) from i18n/dutch-native-review into main
ci / version (push) Skipped
ci / node (push) Successful in 2m37s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 16s
2026-09-27 23:01:42 +00:00
jcoffey-dev 2c4d6cdfae Link Michael's profile from the Dutch credits
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m40s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 30s
(cherry picked from commit 9840a537834082bfd9a028a170ce2d6ac60ddf35)
2026-09-27 15:57:28 -07:00
jcoffey-dev f95072ab92 Credit Michael (mbjboon82) for the Dutch review
Name the reviewer in both Dutch catalogs, FEATURES and ROADMAP, under both of his handles, and record that his wording stands.

(cherry picked from commit aaa86e96d66e2431a8c98467712a977d4035bf76)
2026-09-27 15:57:28 -07:00
jcoffey-dev ab759143ab Take Dutch out of Beta with the native speaker's final review
Michael (mbjboon-netizen) sent the final corrections for nl.ts and the Dutch
permission labels and signed the language off, so Nederlands no longer
carries the Beta flag in the picker.

The main catalog changes 52 values, mostly "regels" -> "filterregels" and
"post" -> "e-mail(s)". The permission headings move from compound nouns
("Accountbeheer") to verb phrases ("Accounts beheren"), and the reviewer's
note on that is kept in the file. No keys were added or removed, and every
placeholder is intact.

One entry is kept as it was: "It {damage}, ..." stays "Het {damage}, ...".
{damage} is filled with a verb phrase ("stops in the middle of a line"), so
the added "is" would have doubled the verb.

README, FEATURES, ROADMAP and KNOWN-ISSUES now say Dutch has been reviewed
and the other eight have not.

(cherry picked from commit e30fd73d7dbb1463859efbc4048f680d21d64c56)
2026-09-27 15:57:23 -07:00
jcoffey-dev d0f7c6ed20 Merge pull request 'Open locked accounts handed to you, beside your own mail' (#22) from feature/delegated-accounts into main
ci / version (push) Skipped
ci / node (push) Successful in 2m45s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 15s
2026-09-27 22:55:38 +00:00
jcoffey-dev 4fcc8dd1b9 Show every folder of a locked account in view
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m25s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 30s
Folder subscriptions are the reader's own and they have none in an
account handed to them, so only Inbox showed. Every folder shows while a
locked account is in view, and Hide from list is gone there.
2026-09-27 15:50:51 -07:00
jcoffey-dev 8674b70f62 Open locked accounts handed to you, beside your own mail
When the server hands a locked account to the reader (urn:inbuxa:jmap
delegation), the account popover offers it. Only mail follows the switch;
the reader's own settings, push and notifications stay theirs. A red bar,
a red wordmark with a padlock and the tab title say which account is in
view. Read delegates can't change anything, organize delegates can't
delete, and writing needs send-as. A delegation taken away drops back to
the reader's own mail.

14 new strings in all nine catalogs, unreviewed (inbuxa AL-7, AL-8).
2026-09-27 14:32:10 -07:00
jcoffey-dev ffe1a898f5 Merge pull request 'Version the brand images URLs' (#21) from brand/versioned-images into main
ci / version (push) Skipped
ci / node (push) Successful in 2m46s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 17s
2026-09-27 05:53:40 +00:00
jcoffey-dev 0adc402629 Version the brand images' URLs, so a new mark reaches returning visitors
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m25s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 37s
The logo, favicons and app icons are served from public/img under fixed
names with a browser cache of hours, and the service worker fetches them
through that cache. After the mark changed on 2026-09-27, returning
visitors kept the old cat until their copies expired, and the favicon
and an installed app's icon hold on longer still.

Every URL that names one now carries ?v=BRAND_V (src/lib/brand.ts,
brandImage()): the header, sign-in, About, the mail empty state, the
notification icons, index.html's favicon links, the manifest's icons
and the service worker's shell and notification icons. Date-stamped,
never a counter, for the sites' ASSET_V reason; the three static files
carry the value written out, and the comment says to keep them in step.
2026-09-26 22:50:22 -07:00
jcoffey-dev 2a30a32c79 Merge pull request 'Brand: inbuxa own kitten replaces ihasmail cat' (#20) from brand/new-mark into main
ci / version (push) Skipped
ci / node (push) Successful in 2m29s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 3m19s
2026-09-27 05:12:38 +00:00
jcoffey-dev ee41846c2d Brand: inbuxa's own kitten replaces ihasmail's cat
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m43s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m7s
The webmail showed ihasmail's cat-and-envelope as inbuxa's mark. The new
mark keeps the family's face, paws and colors, over a server with a bay
for each piece of the suite: the letter (webmail), a prompt (console),
status lights (server).

- img/inbuxa-mark.png (header, sign-in, About) and img/logo.png (the
  mail empty state and the custom-name fallback).
- favicon.ico, favicon-64, apple-touch-icon (opaque white, as before),
  icon-192/512, and icon-maskable, now on an opaque ground with the
  mark inside the safe circle.
- Login.tsx: 120x126, the new mark's proportions; 120x143 would have
  stretched it. Every other use sizes by one dimension.
- The service worker fetches images network-first, so installed copies
  pick the new ones up without a cache version bump.
2026-09-26 22:03:19 -07:00
jcoffey-dev 6cb3321022 Merge pull request 'Announce releases on the community forum' (#19) from announce-releases into main
ci / version (push) Skipped
ci / node (push) Successful in 2m6s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 13s
2026-09-27 02:37:12 +00:00
jcoffey-dev b407969849 Announce releases on the community forum
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m38s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 2m22s
announce.yml runs coffey-labs/actions discourse-release on every published
release, posting it to this project's Announcements category on
community.coffeylabs.org. The release workflow also announces
from its own job, since a release made with the job token fires no
'on: release' workflow in Gitea.
2026-09-26 19:28:12 -07:00
jcoffey-dev 654d298a18 Merge pull request 'About: present inbuxa as the suite, not only the webmail' (#18) from fork/about-suite into main
ci / version (push) Successful in 26s
ci / node (push) Successful in 2m56s
ci / docker-build (push) Skipped
ci / publish (push) Successful in 3m41s
2026-09-26 04:21:58 +00:00
jcoffey-dev dfbfc38258 About: present inbuxa as the suite, not only the webmail
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m25s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 29s
The lead now says what inbuxa is: a mail server, its administration
console and this webmail, installed together under the AGPL, with the
name set apart in the brand teal. A new "The suite" table lists the
mail server, the console (linked, for sessions that may administer),
this webmail's version and inbuxa.org.

4 new strings in all 9 catalogues; the old webmail-only lead is dropped
from them, since nothing looks it up any more.
2026-09-25 21:18:13 -07:00
jcoffey-dev 8fef2c208d Merge pull request 'About: the mail node by hostname, found with resolvePtr' (#17) from fork/about-ptr-fix into main
ci / version (push) Skipped
ci / node (push) Successful in 2m25s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 15s
2026-09-26 04:18:11 +00:00
jcoffey-dev 66673bc9d1 About: show the mail node's hostname, not its address
ci / docker-build (pull_request) Successful in 31s
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m28s
ci / publish (pull_request) Skipped
The address is only shown when the node has no PTR record.
2026-09-25 21:14:54 -07:00
jcoffey-dev 41fb7875d0 About: find the mail node's name with resolvePtr
ci / version (pull_request) Skipped
ci / node (pull_request) Canceled after 36s
ci / docker-build (pull_request) Canceled after 0s
ci / publish (pull_request) Canceled after 0s
dns.reverse came back empty inside the image while the resolver answered
the PTR, so About showed only the address. Ask for the PTR record of the
in-addr.arpa / ip6.arpa name directly.
2026-09-25 21:14:13 -07:00
jcoffey-dev 3507a21599 Merge pull request 'About: name the webmail node and the mail server node' (#16) from fork/about-node-identity into main
ci / version (push) Skipped
ci / node (push) Successful in 2m25s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 15s
2026-09-26 04:05:46 +00:00
jcoffey-dev 6b44705bfd About: name the webmail node and the mail server node
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m26s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 53s
Settings > About shows which webmail node answered (NODE_NAME, else the
container hostname) and which inbuxa node it talks to: the address the
server's name resolves to from the webmail, named by its PTR record. The
server only tells administrators its node name, so the webmail works it
out itself. Fetched from /api/about/nodes on every visit, cached for a
minute server-side, for troubleshooting a cluster.

Fork-only: upstream ihasmail runs one webmail against one server.
4 new strings, translated in all 9 catalogues.
2026-09-25 20:46:04 -07:00
jcoffey-dev 119548090e Merge pull request 'Show the language model's opinion on a message' (#15) from feature/llm-opinion into main
ci / version (push) Skipped
ci / node (push) Successful in 2m22s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 2m43s
Reviewed-on: inbuxa/ihasmail-inbuxa#15
2026-09-23 05:39:19 +00:00
jcoffey-dev e4926cfa7d Show the language model's opinion on a message
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m23s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m24s
When inbuxa-server's AI spam classification is on, it records the model's
answer in an X-Spam-LLM header: a tag (LLM_<category>[_<confidence>]) and,
in parentheses, the model's explanation. The full message now asks for it,
and where it's there:

- the message details show "Language model's opinion" beside the spam
  filter's own working, with category, confidence and explanation;
- a message in Junk carries a banner saying the same.

Both say it's one of several signals the spam filter weighed, never the
reason on its own, as the server's spec requires. The explanation is model
output and is only ever rendered as text. Nothing shows without the header,
so a server without the feature, or with it off, looks as before.

Translations: two new strings, "Language model's opinion" and "One of
several signals the spam filter weighed", in all eight catalogues (16
entries). Category and confidence come from the server and aren't
translated.
2026-09-22 22:03:29 -07:00
jcoffey-dev c0c892dd33 Merge pull request 'Use the server's renamed registry capability, urn:inbuxa:jmap:registry' (#14) from fork/rename-upstream-identifiers into main
ci / version (push) Skipped
ci / node (push) Successful in 1m25s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 16s
Reviewed-on: inbuxa/ihasmail-inbuxa#14
2026-09-23 04:25:12 +00:00
jcoffey-dev f5dd4e5537 Use the server's renamed registry capability, urn:inbuxa:jmap:registry
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m47s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 3m55s
inbuxa-server renames the identifiers that carried the upstream name (its
SPEC.md §2.4). Upstream's capability for the registry (x:) objects is now
urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap, which is
unchanged. There's no alias, so this lands with the server change and
deploys with it. The mock advertises the new name too. No user-visible
strings change.
2026-09-22 19:00:19 -07:00
jcoffey-dev e7ee09d228 Merge pull request 'Lowercase the name in the page title' (#13) from brand/lowercase-title into main
ci / version (push) Skipped
ci / node (push) Successful in 2m23s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 1m17s
2026-09-23 00:16:23 +00:00
jcoffey-dev 1752276229 Lowercase the name in the page title
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m37s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 40s
The browser tab, and anything that takes its name from the document title,
read INBUXA. The manifest, the server's app name and the sign-in card all
have it lowercase; the title was the one place left in caps.

Prod's APP_NAME override was set to inbuxa at the same time; the code
default already was.
2026-09-22 17:13:39 -07:00
jcoffey-dev acd9cff4ea Merge pull request 'Take the upstream name out of the mock's sample data' (#12) from mock/brand-sample-data into main
ci / version (push) Skipped
ci / node (push) Successful in 1m19s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 15s
2026-09-22 23:06:53 +00:00
jcoffey-dev 413ece3bca Take the upstream name out of the mock's sample data
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m16s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 33s
The mock inbox showed a sender called "Stalwart Labs" at [email protected],
a "Welcome to Stalwart!" subject, a link to stalw.art in the sample HTML
and a start-up banner tagged [mock-stalwart]. None of that belongs in this
fork, and it turns up in any screenshot taken from the mock.

Sample senders and subjects now name inbuxa, the sample link points at
inbuxa.org, and the banner says [mock-server].
2026-09-22 16:03:31 -07:00
jcoffey-dev 4bbfd7d455 Merge pull request 'Lowercase the tab title fallback' (#11) from fix/brand-lowercase-title into main
ci / version (push) Skipped
ci / node (push) Successful in 2m20s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 17s
2026-09-22 22:24:12 +00:00
jcoffey-dev d0832013fe Lowercase the tab title's fallback name
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m46s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 37s
The title the tab falls back to before the session names the app was the one
user-visible string the brand pass missed; setBaseTitle overwrites it as soon
as the session arrives, so it shows only for that first moment.
2026-09-22 15:21:35 -07:00
jcoffey-dev b79fdb8bab Merge pull request 'Write the name in lowercase where people see it' (#10) from fix/brand-lowercase into main
ci / version (push) Skipped
ci / node (push) Successful in 2m28s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 16s
2026-09-22 22:18:06 +00:00
jcoffey-dev 8d717e0037 Write the name in lowercase where people see it
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m21s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m10s
The brand is lowercase inbuxa. This changes what the app calls itself by
default, the wordmark's accessible name, the sign-in card's version line, the
installed app's name in the manifest, and the four translated strings that
name the console or the mail server.

Those four are source strings, so their catalog keys changed with them in all
nine languages; the translations keep their text with the name corrected. No
key was left behind, and no language falls back on more strings than before:
1643/1662 translated, 19 falling back, in each of the nine, unchanged.

Code identifiers, capability URNs, env var names and comments are untouched.
2026-09-22 15:13:53 -07:00
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
192 changed files with 12961 additions and 3782 deletions

No files matched your search

+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/inbuxa-webmail
# ---- 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.
+26
View File
@@ -0,0 +1,26 @@
# Announce each published release on the community forum, in this project's
# Announcements category (coffey-labs/actions discourse-release; the repo ->
# category map is its release-map.json). Safe to re-run: one topic per tag.
# Run it by hand with a tag to announce a release whose own run failed: a
# release event runs the workflow as it was at the tag, so a fix on main only
# reaches an old release this way.
name: announce
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: release tag to announce, e.g. inbuxa-v2026.10.5-g17a8093
required: true
jobs:
announce:
runs-on: light
steps:
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
with:
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
tag: ${{ inputs.tag }}
+247
View File
@@ -0,0 +1,247 @@
# 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 is the GitHub side, below.
#
# WHERE THE BUILD RUNS. Gitea push-mirrors this repository to GitHub, and the
# org variable BUILD_ON picks which forge does the heavy work:
# * unset (or anything but `github`): every job here runs, as it always did,
# and GitHub's workflow skips all of its jobs.
# * `github`: the test, build and publish jobs here are skipped, GitHub
# Actions runs .github/workflows/ci.yml on its hosted runners (native
# arm64, no QEMU), and the `github` job below waits for the commit status
# that run posts back, passing or failing with it. So this run's result is
# still the one that counts, for a PR's checks as for anything that merges
# on green CI. The variable is set on both forges, and must agree.
# If GitHub is ever unavailable, unsetting BUILD_ON here is the whole
# fallback: the jobs below take over again unchanged.
#
# 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:
if: ${{ vars.BUILD_ON != 'github' }}
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: ${{ vars.BUILD_ON != 'github' && !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: ${{ vars.BUILD_ON != 'github' && 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/inbuxa-webmail, 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: ${{ vars.BUILD_ON != 'github' && 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
# The release above is made with the job's own token, and Gitea starts no
# workflow for events the Actions bot causes -- announce.yml's
# 'on: release' never fires for it -- so announce it from here. With
# BUILD_ON=github the release is created by GitHub's run instead, and this
# follows the `github` job. Announcing stays on Gitea either way; the
# action posts once per tag, so a second attempt is a no-op.
announce:
needs: [publish, github]
if: ${{ always() && startsWith(github.ref, 'refs/tags/inbuxa-v') && (needs.publish.result == 'success' || needs.github.result == 'success') }}
runs-on: light
steps:
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
with:
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
tag: ${{ github.ref_name }}
# ------------------------------------------------------------ github ------
# With BUILD_ON=github, the work above happens in GitHub Actions, which
# posts one commit status back here when it finishes: "github/ci (branch)"
# for a branch push, "github/ci (tag)" for a tag. This job waits for that
# status on the commit under test -- the PR's head for a pull request -- and
# passes or fails with it. Nothing arriving within the timeout means GitHub
# never built the commit (a mirror that failed to sync, or GitHub being
# down): check the mirror, or unset BUILD_ON to build here.
github:
if: ${{ vars.BUILD_ON == 'github' }}
# Its own runner label with plenty of slots: this job only polls, but holds a slot
# for as long as the GitHub build takes, and must not starve the build runners.
runs-on: wait
timeout-minutes: 150
container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
CONTEXT: github/ci (${{ github.ref_type == 'tag' && format('tag {0}', github.ref_name) || 'branch' }})
steps:
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl jq >/dev/null
- shell: bash
run: |
set -uo pipefail
url="$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/commits/$SHA/statuses?limit=50"
echo "waiting for '$CONTEXT' on $SHA"
while :; do
state="$(curl -fsS -H "Authorization: token $TOKEN" "$url" \
| jq -r --arg c "$CONTEXT" '[.[] | select(.context == $c)] | sort_by(.id) | last | .status // empty')"
case "$state" in
success) echo "GitHub reported success"; exit 0 ;;
failure|error) echo "GitHub reported $state -- see the status's link for the run" >&2; exit 1 ;;
esac
sleep 20
done
+1 -1
View File
@@ -14,7 +14,7 @@
## Translations
<!--
Nine languages ship alongside English, and a missing key silently renders
Ten 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:
-44
View File
@@ -1,44 +0,0 @@
version: 2
updates:
# The npm entry sits at the root because that is where the single lockfile
# is: root, server and web are one npm workspace, so one entry covers all
# three. Pointing entries at server/ or web/ would find package.json files
# with no lockfile beside them and update nothing.
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
open-pull-requests-limit: 5
groups:
# Everything routine arrives as one PR a week, so the dashboard is not
# the only place these get noticed. Majors are deliberately left out of
# the group: they are migrations, not bumps -- vitest 3 to 4 is one --
# and each deserves its own PR and its own CI run.
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
groups:
actions:
patterns:
- "*"
# The runtime and build stages both pin node:22-alpine, so this is what
# keeps the published container images off a stale base between the weekly
# releases.
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
+330 -24
View File
@@ -1,39 +1,345 @@
name: CI
# CI and publishing on GitHub Actions, for a repository whose source of truth
# is the self-hosted Gitea. Gitea push-mirrors every commit and tag here, and
# this workflow does the heavy work on GitHub's hosted runners -- native arm64
# included -- then reports the result back to Gitea as a commit status.
#
# THE SWITCH. Every job here runs only when the org variable BUILD_ON is
# `github`. Gitea's .gitea/workflows/ci.yml reads the same variable (set on
# the Gitea org too): with it set, Gitea skips its own build jobs and waits for
# the status this workflow posts; without it, Gitea builds everything itself,
# exactly as before, and every job here is skipped. If GitHub is ever
# unavailable, unsetting BUILD_ON on Gitea is the whole fallback.
#
# There is no pull_request trigger: pull requests live on Gitea. A PR's branch
# arrives here as an ordinary push, and the status lands on its head commit,
# which is where Gitea's PR looks for it.
#
# Releases are cut by pushing a tag named `inbuxa-v<version>` (see
# .gitea/workflows/ci.yml for why the prefix matters: this repository carries
# upstream ihasmail's own `v...` tags, and only `inbuxa-v` tags publish).
#
# Org configuration, not in this file:
# vars.BUILD_ON `github` to build here
# vars.REGISTRY the Gitea container registry's DNS-only name
# vars.GITEA_URL Gitea's public URL, for statuses, releases and packages
# secrets.GITEA_TOKEN jcoffey-dev, write:repository + write:package
#
# Every `uses:` is pinned to a full commit SHA with the release in the
# trailing comment. A tag is a mutable pointer, so trusting `@v7` is trusting
# every future version of that action. Do not "simplify" a pin back to a tag.
name: ci
on:
push:
branches: [main]
pull_request:
# Lets CI be run by hand against any ref, including a specific commit.
# Without this there is no way to re-run a check that never started: a run
# GitHub queues and then orphans -- as it did to every run created during the
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
# nor canceled ("already completed"), and the workflow has no other trigger
# to reach for. Useful too for putting a check on a commit that predates a CI
# change, without pushing an empty commit to move it.
branches: ['**']
tags: ['**']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GITEA_URL: ${{ vars.GITEA_URL }}
REGISTRY: ${{ vars.REGISTRY }}
# A registry path must be lowercase; the repository name already is.
IMAGE: ${{ vars.REGISTRY }}/inbuxa/inbuxa-webmail
# A tag's context names the tag: upstream v* and inbuxa-v* tags can sit on
# the same commit, and Gitea must not read one tag's result as the other's.
STATUS_CONTEXT: github/ci (${{ github.ref_type == 'tag' && format('tag {0}', github.ref_name) || 'branch' }})
jobs:
build:
# Tells Gitea a result is on its way, so a PR shows the check as running
# rather than missing.
pending:
if: ${{ vars.BUILD_ON == 'github' }}
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
jq -n --arg c "$STATUS_CONTEXT" \
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' \
| curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" --data @- \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
# -------------------------------------------------------------- test ------
# version.test.ts shells out to git to resolve a build version from the
# history, so the checkout is a full one. The hosted runner runs as an
# unprivileged user, so config.test.ts's read-only directory holds here
# without the `su node` Gitea needs.
node:
if: ${{ vars.BUILD_ON == 'github' }}
runs-on: ubuntu-latest
steps:
# 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
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
cache: npm
# --ignore-scripts: a postinstall script in any transitive dependency
# would otherwise run with the job's credentials in its environment.
- run: npm ci --ignore-scripts
- run: npm run typecheck
- run: npm test
- run: npm run build
- name: Docker build
run: docker build -t ihasmail:ci .
# ------------------------------------------------------------- build ------
# Proves the Dockerfile still builds on every change, without pushing.
docker-build:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
needs: [node]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: docker build -t "ihasmail:ci-${GITHUB_SHA::8}" .
# ----------------------------------------------------------- release ------
# Only for `inbuxa-v` tags. 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: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
docker_tag: ${{ steps.v.outputs.docker_tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: v
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
V="$(node scripts/version.mjs)"
want="inbuxa-v${V/+/-}"
[ "$TAG" = "$want" ] || { echo "::error::$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 "::error::$TAG is not on main"; exit 1; }
echo "version=$V" >> "$GITHUB_OUTPUT"
# A Docker tag may not contain '+', so build metadata becomes '-'.
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "version $V -> tag ${V/+/-}"
# Each architecture on its own native runner, pushed as an untagged image by
# digest; `publish` joins the two digests into one multi-arch tag.
build:
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
needs: [node, version]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- name: Build and push by digest
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
# Attestations add manifests of their own to the index, and
# `imagetools create` below expects the two entries pushed here.
provenance: false
sbom: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Save the digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
mkdir -p /tmp/digests
# Bare hash as the filename; the prefix is put back when joining.
touch "/tmp/digests/${DIGEST#sha256:}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
# Kept a week, and overwritable, so a re-run of the build or of
# publish alone still finds (or replaces) the digests.
retention-days: 7
overwrite: true
if-no-files-found: error
# Joins the digests into `:<version>` and `:latest`, links the package to
# the repository on Gitea, then creates the release there -- last, so a
# release on the page always has its image behind it. The release is made
# with GITEA_TOKEN, a user's token, so Gitea's announce.yml fires for it;
# Gitea's ci.yml announces as well once this run's status arrives, and the
# announce action posts once per tag whichever gets there first.
publish:
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
needs: [version, build]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- name: Create the manifest
env:
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
run: |
refs=()
for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")"); done
docker buildx imagetools create -t "${IMAGE}:${DOCKER_TAG}" -t "${IMAGE}:latest" "${refs[@]}"
docker buildx imagetools inspect "${IMAGE}:${DOCKER_TAG}"
# Shows the package on the repository's Packages tab. Idempotent.
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/packages/inbuxa/container/inbuxa-webmail/-/link/inbuxa-webmail" \
|| echo "package already linked (or link refused); not fatal"
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }}
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
run: |
set -eu
API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
# A re-run finds the release already there.
if curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" "$API/tags/$TAG"; then
echo "release $TAG already exists"; exit 0
fi
body="$(printf 'INBUXA webmail %s.\n\nImage: `%s:%s` (linux/amd64, linux/arm64), also tagged `latest`.' "$VERSION" "$IMAGE" "$DOCKER_TAG")"
jq -n --arg tag "$TAG" --arg body "$body" '{tag_name:$tag, name:$tag, body:$body}' \
| curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
--data @- "$API"
echo "release $TAG created"
# ------------------------------------------------------ ghcr replica ------
# Copies the release image from the Gitea registry, which stays the
# authoritative one, to ghcr.io under the same version tag and :latest. It is
# a copy, not a second build: the digest on GHCR is the digest on the
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
# out of the report to Gitea, like the release copy, so a GHCR problem
# cannot fail a release.
ghcr:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
needs: [version, publish]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.version.outputs.docker_tag }}
run: |
set -euo pipefail
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
tag="$TAG"
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag"
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
echo "registry $src:$tag = $want"
echo "ghcr $dst:$tag = $got"
[ "$want" = "$got" ] || echo "::warning::GHCR digest differs from the registry's"
docker logout ghcr.io
# ---------------------------------------------------- github release ------
# Copies this tag's Gitea release -- notes and files -- to a GitHub release,
# so the replica's Releases page, and anyone watching it, keeps up. Gitea's
# release is the real one; this is left out of the report to Gitea, so a
# failure here cannot fail a release. PR and issue numbers in the notes are
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
github-release:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
needs: [publish]
runs-on: ubuntu-latest
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
steps:
- run: |
set -euo pipefail
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "GitHub already has a release for $TAG"; exit 0
fi
# The Gitea release exists by now if this run made it; if the weekly
# release job made it, it came before the tag. Allow a few minutes.
code=0
for _ in $(seq 1 15); do
code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")"
[ "$code" = 200 ] && break
sleep 20
done
if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi
if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi
export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)"
jq -r '.body // ""' rel.json | perl -pe 's{(?<![\w/&\[])#(\d+)\b}{[#$1]($ENV{BASE}/pulls/$1)}g' > notes.md
printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \
"$(jq -r .html_url rel.json)" "$BASE" >> notes.md
files=()
mkdir -p files
while IFS=$'\t' read -r name url; do
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
--notes-file notes.md "$kind" "${files[@]}"
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
# One commit status on Gitea for the whole run: what Gitea's ci.yml waits
# for, and what a Gitea PR shows.
report:
if: ${{ always() && vars.BUILD_ON == 'github' }}
needs: [pending, node, docker-build, version, build, publish]
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }}
run: |
# A cancelled run was superseded by a newer run for the same commit (the
# mirror can push one commit twice); that run reports. Posting "failure"
# here would fail the Gitea check while the real build is still going.
if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi
jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' \
| curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" --data @- \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
echo "reported $STATE as '$STATUS_CONTEXT'"
-69
View File
@@ -1,69 +0,0 @@
# Prune old image versions from GHCR.
#
# Releases are kept forever -- they carry no assets and their generated notes
# are this project's only changelog, so deleting one destroys history that
# cannot be reconstructed for nothing saved. Images are the opposite: a
# multi-arch build a week, and the by-digest push in publish.yml leaves two
# untagged per-architecture manifests behind each time on top of the tagged
# index. Those accumulate and nobody wants fifty of them.
#
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
# `delete-only-untagged-versions` -- will happily delete the per-architecture
# manifests that a multi-arch tag points *at*, because they are untagged by
# design. Nothing appears to break: the tag still exists, and pulls simply
# start failing for one architecture. This action understands manifest lists
# and will not orphan a retained index, and `validate` re-checks every
# multi-arch manifest against the registry afterwards.
#
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
# exactly what would be deleted without rebuilding and re-pushing an image to
# find out.
name: Prune images
on:
workflow_call:
inputs:
dry_run:
type: boolean
default: false
workflow_dispatch:
inputs:
dry_run:
description: "List what would be deleted, delete nothing"
type: boolean
default: true
jobs:
prune:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
# 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
token: ${{ secrets.GITHUB_TOKEN }}
# Ten weekly releases is roughly a quarter of history, which is more
# than enough to roll back to and far less than the year's worth that
# would otherwise pile up. Older *releases* stay either way; this
# only removes the images.
keep-n-tagged: 10
# Belt and braces on top of the action's own manifest awareness:
# `latest` is never a candidate for deletion under any counting.
exclude-tags: latest
delete-untagged: true
# Sweeps the wreckage of a half-failed run: an index whose platform
# images did not all land, and referrers whose parent is gone.
delete-partial-images: true
delete-orphaned-images: true
# Checks every remaining multi-architecture manifest still resolves
# in the registry. This is the step that would catch the footgun
# above rather than leaving a reader to discover it on `docker pull`.
validate: true
dry-run: ${{ inputs.dry_run }}
-202
View File
@@ -1,202 +0,0 @@
# Publish the container image to GHCR.
#
# The README and the docs site have told people to run
# `ghcr.io/coffey-labs/ihasmail:latest` for a long time, and nothing ever
# pushed it: `docker pull` answered `denied`, because the package did not
# exist. This is the workflow that makes those instructions true. It is also
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
# both install by pulling an image and neither builds from source.
#
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
# a public repository, and an anonymous `docker pull` will still answer
# `denied`. Nothing in a workflow can change that -- the visibility is set once
# by hand under the package's settings, and until it is, this looks like it
# worked while the docs stay just as wrong as before. Check with a logged-out
# pull, not with one from a machine that has credentials.
#
# Two architectures, each built on its own native runner rather than under
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
# instruction translation, which takes tens of minutes and occasionally runs
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
# the same work at native speed. The cost is the by-digest dance below: each
# runner pushes an untagged image, and a final job joins the two digests into
# one multi-arch tag.
name: Publish image
on:
release:
types: [published]
# Callable, so release.yml can build the release it just cut. This is not a
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
# `release` event -- GitHub refuses to let a token trigger another workflow,
# to stop a workflow looping on its own output. A scheduled job that cut a
# release and expected this file to notice would silently never publish. The
# alternatives are a personal access token kept as a secret, or calling the
# workflow directly. This is the one that needs no credential.
workflow_call:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
type: string
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
# orphans can be neither rerun nor canceled, and this workflow otherwise
# only fires on a release -- which is not something to cut twice because a
# runner died. `ref` also allows publishing an image for a tag that predates
# this workflow, which is how the first one gets built.
workflow_dispatch:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
default: main
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
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
jobs:
# The version is worked out once and handed to both builds, so the two
# architectures cannot disagree about what they are. scripts/version.mjs
# reads the commit date and how the commit arrived, so it needs real history
# rather than a shallow clone.
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
docker_tag: ${{ steps.v.outputs.docker_tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: v
run: |
V="$(node scripts/version.mjs)"
echo "version=$V" >> "$GITHUB_OUTPUT"
# A Docker tag may not contain '+', so build metadata becomes '-'.
# The build is still *told* the real form, which is what About and
# /api/health report.
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "version $V -> tag ${V/+/-}"
build:
needs: version
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- 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@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
# Attestations are off deliberately: they add manifests of their own
# to the index, and `imagetools create` below expects the two entries
# it pushed rather than four.
provenance: false
sbom: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Save the digest
run: |
mkdir -p /tmp/digests
# The prefix is stripped here and put back in the merge job, so the
# filename is the bare hash. Leaving it on produces
# `image@sha256:sha256:...` when the reference is rebuilt.
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- 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 }}
path: /tmp/digests/*
retention-days: 1
if-no-files-found: error
# Joins the per-architecture digests into a single tagged manifest, so
# `docker pull ghcr.io/coffey-labs/ihasmail:<tag>` resolves on both.
publish:
needs: [version, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- 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: Create the manifest
run: |
# Arrays rather than a string: the tags and the digest references
# have to reach docker as separate arguments, and building them by
# word-splitting an unquoted variable is the version of this that
# breaks the day a value contains a space.
tags=(-t "${IMAGE}:${{ needs.version.outputs.docker_tag }}")
# :latest follows real releases only. A prerelease that moved it
# would hand every `:latest` deployment an unfinished build, and a
# dispatch run has to ask for it on purpose.
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
tags+=(-t "${IMAGE}:latest")
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
tags+=(-t "${IMAGE}:latest")
fi
refs=()
for f in /tmp/digests/*; do
refs+=("${IMAGE}@sha256:$(basename "$f")")
done
echo "tags: ${tags[*]}"
echo "refs: ${refs[*]}"
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
- name: Show what landed
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.docker_tag }}"
# Runs only after a successful publish, because that is the only moment the
# package grows. See cleanup.yml for why this is not the obvious one-liner.
prune:
needs: publish
permissions:
packages: write
uses: ./.github/workflows/cleanup.yml
-163
View File
@@ -1,163 +0,0 @@
# Cut a release once a week, but only if there is something in it.
#
# Releases had drifted 184 commits behind main, which made `:latest` describe
# a build nobody was running -- the demo, prod and anyone building from source
# were all ahead of it. Publishing on release is the right trigger only if
# releases actually happen, so this is the part that makes that true without
# anyone having to remember.
#
# It does nothing on a quiet week. A release with no commits in it is worse
# than no release: it moves `:latest` to an identical build, spends a version
# number, and mails everybody watching the repository about nothing.
name: Weekly release
on:
schedule:
# Mondays, 09:17 UTC. GitHub runs scheduled jobs on a best-effort basis and
# can delay a run by a good while when the queue is busy, so do not read
# the exact minute as a promise. The odd minute is deliberate: the top of
# the hour is when most schedules fire, and at 09:00 the first scheduled
# run started almost six hours late and the second had not started at all
# four and a half hours in. Moving off the hour does not make GitHub keep
# time, but it stops competing for the busiest slot. A missed week can be
# cut by hand with workflow_dispatch; a late scheduled run that follows
# finds the tag already there and does nothing.
#
# Note also that GitHub disables scheduled workflows in a repository with
# no activity for 60 days -- not a concern while this one is being worked
# on weekly, but it is why a silent stop is worth checking for before
# assuming the file is broken.
- cron: "17 9 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Work out what would be released, then stop"
type: boolean
default: false
# One at a time. Two overlapping runs would race to create the same tag, and
# the loser fails noisily for a reason that has nothing to do with the code.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
tag: ${{ steps.decide.outputs.tag }}
title: ${{ steps.decide.outputs.title }}
sha: ${{ steps.decide.outputs.sha }}
previous: ${{ steps.decide.outputs.previous }}
count: ${{ steps.decide.outputs.count }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: decide
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# The newest published release, or empty on a repository that has
# never had one -- in which case everything counts as new. Drafts are
# excluded: an unpublished draft is not a release anybody has, so
# counting from it would hide commits that have never shipped.
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
# A tag named by a release is normally present after a full checkout,
# but a release can outlive its tag. Falling back to the whole
# history is the safe direction to be wrong in: it over-counts, which
# cuts a release that was due anyway, where under-counting would skip
# one that was.
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
count="$(git rev-list --count "${previous}..HEAD")"
else
count="$(git rev-list --count HEAD)"
fi
version="$(node scripts/version.mjs)"
# A Docker tag may not contain '+', and neither should the git tag,
# so the two always agree about what to call a build.
tag="v${version/+/-}"
title="v${version%%+*}"
sha="$(git rev-parse HEAD)"
should_release=true
reason=""
if [ "$count" -eq 0 ]; then
should_release=false
reason="no commits since ${previous}"
elif git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
# Same commit, different week: the version is derived from the
# commit, so nothing new means the tag already exists.
should_release=false
reason="tag ${tag} already exists"
fi
{
echo "should_release=$should_release"
echo "tag=$tag"
echo "title=$title"
echo "sha=$sha"
echo "previous=$previous"
echo "count=$count"
} >> "$GITHUB_OUTPUT"
# Written to the run summary so a skipped week reads as a decision
# rather than as a workflow that quietly did nothing.
{
echo "### Weekly release"
echo
if [ "$should_release" = "true" ]; then
echo "Releasing **${tag}** — ${count} commit(s) since ${previous:-the beginning}."
else
echo "Nothing to release: ${reason}."
fi
} >> "$GITHUB_STEP_SUMMARY"
cut:
needs: check
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
args=(--target "${{ needs.check.outputs.sha }}"
--title "${{ needs.check.outputs.title }}"
--generate-notes)
# Bound the notes to what is actually new. Without a start tag the
# generator reaches back to whatever it decides is previous, which on
# a repository with older tag shapes is not always the last release.
if [ -n "${{ needs.check.outputs.previous }}" ]; then
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
fi
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
# Called rather than left to the `release` trigger on purpose: see the note
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
# event, so without this the tag would exist and no image would follow it.
publish:
needs: [check, cut]
permissions:
contents: read
packages: write
uses: ./.github/workflows/publish.yml
with:
ref: ${{ needs.check.outputs.sha }}
tag_latest: true
+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
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**johnellisATlinuxDOTcom**.
**communityATcoffeylabsDOTorg**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
+29 -29
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
@@ -73,13 +73,14 @@ start it, and neither will closing and reopening.
### Translations
Nine languages ship alongside English: German, Spanish, French, Dutch,
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, in
Ten languages ship alongside English: German, Spanish, French, Dutch,
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese, Japanese and
Turkish, in
`web/src/locales/`. A missing key renders its English source rather than
failing, so an untranslated string is invisible until somebody reading that
language finds it.
**Any change that adds or alters a user-visible string adds work in all nine
**Any change that adds or alters a user-visible string adds work in all ten
catalogs.** Say so explicitly in the PR — how many keys, and the fallback
count before and after — and say so just as explicitly when a change adds none,
so it is never left to be inferred.
@@ -94,7 +95,7 @@ plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
is keyed on **`"Deleted {n} contacts"`**. Keying the catalog on the `one`
form type-checks, builds, passes every test, and silently falls back to English
in all nine languages. Nothing errors. The only signal is the fallback count
in all ten languages. Nothing errors. The only signal is the fallback count
going up, so read it:
```sh
@@ -117,7 +118,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__/`.
@@ -128,7 +129,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.
@@ -137,8 +138,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
@@ -153,38 +154,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
@@ -199,9 +200,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/).
@@ -213,7 +213,7 @@ and the single-host `deploy.example.sh`, are covered in
## Reporting Security Issues
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **johnellisATlinuxDOTcom** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **securityATcoffeylabsDOTorg** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
## Questions?
-1857
View File
File diff suppressed because it is too large. Load diff
-158
View File
@@ -1,158 +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).
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)).
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
- **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.
+69 -73
View File
@@ -1,114 +1,110 @@
<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
> [!NOTE]
> Development happens on [git.coffeylabs.org/inbuxa/inbuxa-webmail](https://git.coffeylabs.org/inbuxa/inbuxa-webmail); the copy on GitHub is a read-only mirror.
> Report issues at **[git.coffeylabs.org/inbuxa/inbuxa-webmail/issues](https://git.coffeylabs.org/inbuxa/inbuxa-webmail/issues)**, and join discussions at **[community.coffeylabs.org](https://community.coffeylabs.org)**.
>
> This repository was called `ihasmail-inbuxa` until October 2026. Container images are now published as `inbuxa/inbuxa-webmail`; the old `inbuxa/ihasmail-inbuxa` image stops at `2026.9.26-g654d298`.
**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](screenshots/inbox-dark.jpg) | **Inbox & conversation (light)** ![Inbox, light theme](screenshots/inbox-light.jpg) |
| **Composer** ![Composer](screenshots/compose.jpg) | **Calendar** ![Calendar](screenshots/calendar.jpg) |
| **Contacts** ![Contacts](screenshots/contacts.jpg) | **Sieve filter builder** ![Filters](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.
- **Eleven 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/inbuxa-webmail>. 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 LLC. 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.
+6 -6
View File
@@ -15,20 +15,20 @@ ihasmail is under active development. Security fixes are applied to the latest r
Instead, report security issues privately by emailing:
**johnellisATlinuxDOTcom**
**securityATcoffeylabsDOTorg**
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
+2 -2
View File
@@ -25,11 +25,11 @@ services:
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/inbuxa-webmail}
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",
+4 -4
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");
@@ -47,7 +47,7 @@ after(() => {
});
/**
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
* Stalwart advertises `urn:inbuxa:jmap:registry` only per-account, never in the
* session-level capabilities. Looking for it at the top level alone reported
* every real 0.16 server as older than 0.16 — and now that the same check
* decides whether a sign-in is allowed at all, that mistake would lock
@@ -57,8 +57,8 @@ test("the session is accepted on a server that advertises the registry per-accou
const res = await call("/api/auth/session");
assert.equal(res.status, 200);
assert.equal(res.body.ihasmail.server.edition, "oss");
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
assert.equal(res.body.capabilities["urn:inbuxa:jmap:registry"], undefined, "not where a client would first look");
assert.ok("urn:inbuxa:jmap:registry" in res.body.primaryAccounts, "but here, as on a real server");
});
test("the registry reports an account with nothing set up yet", async () => {
+2 -2
View File
@@ -12,7 +12,7 @@ import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.
* the registry is known to be there.
*/
const STALWART_CAP = "urn:stalwart:jmap";
const STALWART_CAP = "urn:inbuxa:jmap:registry";
const JMAP_CORE = "urn:ietf:params:jmap:core";
/** Stalwart's id for a singleton object; the number it encodes spells this. */
const SINGLETON = "singleton";
@@ -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][] };
}
+3 -3
View File
@@ -44,7 +44,7 @@ test("locales that carry no language are dropped, not passed through", () => {
test("a server without the registry is not asked for anything", async () => {
// Sign-in refuses these, so getAccountInfo should never reach the wire for
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
// one - and must not, since a server that cannot parse `urn:inbuxa:jmap:registry`
// fails the whole request rather than the one call.
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
@@ -57,7 +57,7 @@ test("no capabilities at all is treated the same way", async () => {
});
/**
* Where Stalwart actually advertises `urn:stalwart:jmap`.
* Where Stalwart actually advertises `urn:inbuxa:jmap:registry`.
*
* Not in the session-level `capabilities`: `Session::new` builds those from a
* fixed list that has never carried this capability, in any 0.16.x. It is
@@ -70,7 +70,7 @@ test("no capabilities at all is treated the same way", async () => {
* This check now decides whether a sign-in is allowed at all, so getting it
* wrong would lock every user out of a perfectly good server.
*/
const STALWART = "urn:stalwart:jmap";
const STALWART = "urn:inbuxa:jmap:registry";
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
+200
View File
@@ -0,0 +1,200 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
/**
* inbuxa MA-B: more than one account signed in in one browser. The session
* cookie is the account in front; the others ride in `<name>_more`. Adding
* signs a second account in beside the first, switching swaps them, signing
* out ends only the one in front, and an organization that doesn't allow it
* keeps adding off.
*/
const PORT = 18801;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "first-password";
process.env.MOCK_SECOND_USER = "[email protected]";
process.env.MOCK_SECOND_PASS = "second-password";
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-accounts";
// Every test here signs in several times from one address
process.env.LOGIN_RATE_LIMIT = "100";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const { config } = await import("./config.js");
const { MAX_ACCOUNTS, parseOthers, serializeOthers } = await import("./accounts.js");
const app = createApp();
const FRONT = config.cookieName;
const MORE = `${config.cookieName}_more`;
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
/** A browser's cookie jar, as far as these two cookies go. */
class Browser {
jar = new Map<string, string>();
private take(res: Response) {
for (const line of res.headers.getSetCookie()) {
const [pair, ...attrs] = line.split(";");
const at = pair!.indexOf("=");
const name = pair!.slice(0, at).trim();
const value = pair!.slice(at + 1).trim();
const expired = attrs.some((a) => /max-age=0/i.test(a) || /expires=thu, 01 jan 1970/i.test(a));
if (expired || !value) this.jar.delete(name);
else this.jar.set(name, value);
}
}
async call(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: any }> {
const cookie = [...this.jar].map(([k, v]) => `${k}=${v}`).join("; ");
const res = await app.request(path, {
method: init.method ?? "GET",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}) },
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
});
this.take(res);
const text = await res.text();
return { status: res.status, body: text ? JSON.parse(text) : null };
}
signIn(username: string, password: string, add = false) {
return this.call("/api/auth/login", { method: "POST", body: { username, password, ...(add ? { add: true } : {}) } });
}
async accounts(): Promise<{ username: string; front: boolean; id: string }[]> {
const res = await this.call("/api/auth/accounts");
assert.equal(res.status, 200, JSON.stringify(res.body));
return res.body.accounts;
}
}
test("the cookie list keeps only well-formed session cookies, at most one fewer than the cap", () => {
const good = "abcdefghij.ABCDEFGHIJKLMN";
assert.deepEqual(parseOthers(`${good}~not a cookie~${good}`), [good]);
const many = Array.from({ length: 9 }, (_, i) => `abcdefgh${i}x.ABCDEFGHIJKLMN`);
assert.equal(parseOthers(many.join("~")).length, MAX_ACCOUNTS - 1);
assert.equal(serializeOthers(["bad", good]), good);
});
test("a second account joins the first, and switching swaps them", async () => {
const b = new Browser();
assert.equal((await b.signIn("[email protected]", "first-password")).status, 200);
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
const first = b.jar.get(FRONT);
const added = await b.signIn("[email protected]", "second-password", true);
assert.equal(added.status, 200, JSON.stringify(added.body));
assert.equal(added.body.added, true);
assert.equal(b.jar.get(MORE), first, "the first account moved beside the new one");
let accounts = await b.accounts();
assert.deepEqual(accounts.map((a) => [a.username, a.front]), [["[email protected]", true], ["[email protected]", false]]);
// The same account again is not a second copy
await b.signIn("[email protected]", "first-password", true);
accounts = await b.accounts();
assert.equal(accounts.length, 2);
assert.equal(accounts[0]!.username, "[email protected]", "it came to the front instead");
// Switch back
const second = accounts.find((a) => !a.front)!;
assert.equal((await b.call(`/api/auth/accounts/${second.id}/front`, { method: "POST" })).status, 200);
assert.equal((await b.accounts())[0]!.username, "[email protected]");
// Signing out ends only the one in front; the other comes forward
const out = await b.call("/api/auth/logout", { method: "POST" });
assert.equal(out.body.next, true);
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
// Sign out of all
await b.signIn("[email protected]", "second-password", true);
assert.equal((await b.accounts()).length, 2);
await b.call("/api/auth/logout-all", { method: "POST" });
assert.equal(b.jar.has(FRONT), false);
assert.equal(b.jar.has(MORE), false);
assert.equal((await b.call("/api/auth/accounts")).status, 401);
});
test("an account in front can't switch to one it doesn't hold", async () => {
const b = new Browser();
await b.signIn("[email protected]", "first-password");
assert.equal((await b.call("/api/auth/accounts/not-a-session/front", { method: "POST" })).status, 404);
});
test("an organization that doesn't allow it keeps adding off", async () => {
const b = new Browser();
await b.signIn("[email protected]", "first-password");
process.env.MOCK_NO_ADD_ACCOUNTS = "1";
try {
// The cached upstream session is a minute old at most; ask afresh
await b.call("/api/auth/session?refresh=1");
const res = await b.call("/api/auth/accounts");
assert.equal(res.body.canAdd, false);
const added = await b.signIn("[email protected]", "second-password", true);
assert.equal(added.status, 403);
assert.equal(added.body.error, "add_not_allowed");
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"], "the front stayed");
} finally {
delete process.env.MOCK_NO_ADD_ACCOUNTS;
}
});
test("inbuxa MA-8: the accounts not in front report their Inbox unread count", async () => {
const b = new Browser();
await b.signIn("[email protected]", "first-password");
// Alone, there is nothing to report
assert.deepEqual((await b.call("/api/auth/accounts/unread")).body.accounts, []);
await b.signIn("[email protected]", "second-password", true);
const res = await b.call("/api/auth/accounts/unread");
assert.equal(res.status, 200);
assert.equal(res.body.accounts.length, 1, "only the account not in front");
const [other] = res.body.accounts;
const listed = (await b.accounts()).find((a) => !a.front)!;
assert.equal(other.id, listed.id);
assert.equal(typeof other.unread, "number", JSON.stringify(res.body));
});
test("inbuxa MA-8: only push, mailboxes and marking mail reach an account not in front", async () => {
const { otherAccountCallAllowed } = await import("./app.js");
assert.equal(otherAccountCallAllowed(["PushSubscription/get", { ids: null }, "0"]), true);
assert.equal(otherAccountCallAllowed(["Mailbox/get", { accountId: "a" }, "0"]), true);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { "keywords/$seen": true } } }, "0"]), true);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { mailboxIds: { arch: true } } } }, "0"]), true);
// Anything else is refused
assert.equal(otherAccountCallAllowed(["Email/get", { accountId: "a" }, "0"]), false);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", destroy: ["m1"] }, "0"]), false);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", create: { x: {} } }, "0"]), false);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { subject: "x" } } }, "0"]), false);
assert.equal(otherAccountCallAllowed(["EmailSubmission/set", {}, "0"]), false);
const b = new Browser();
await b.signIn("[email protected]", "first-password");
const added = await b.signIn("[email protected]", "second-password", true);
assert.equal(added.status, 200, JSON.stringify(added.body));
const other = (await b.accounts()).find((a) => !a.front)!;
const ok = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
method: "POST",
body: { using: ["urn:ietf:params:jmap:core"], methodCalls: [["PushSubscription/get", { ids: null }, "0"]] },
});
assert.equal(ok.status, 200, JSON.stringify(ok.body));
assert.equal(ok.body.methodResponses[0][0], "PushSubscription/get");
const refused = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
method: "POST",
body: { using: [], methodCalls: [["Email/get", { accountId: "x", ids: null }, "0"]] },
});
assert.equal(refused.status, 403);
// The account in front isn't reached this way, nor a session not held here
const front = (await b.accounts()).find((a) => a.front)!;
assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404);
});
test("inbuxa MA-8: each listed account says which mail account it is", async () => {
const b = new Browser();
await b.signIn("[email protected]", "first-password");
await b.signIn("[email protected]", "second-password", true);
const res = await b.call("/api/auth/accounts");
for (const a of res.body.accounts) assert.equal(typeof a.mailAccountId, "string", JSON.stringify(a));
});
+58
View File
@@ -0,0 +1,58 @@
/**
* More than one signed-in account in a browser (multi-account spec, MA-B).
*
* The session cookie is unchanged: it is the account in front, and every
* request is answered with it, so nothing else in the server has to know
* there may be others. The others ride in a second cookie, `<name>_more`: a
* list of their own session cookies, each `id.secret` exactly as the front one
* is. Switching swaps one of them into front; adding moves the front one into
* the list. Each session stays its own -- its own sealed credential, its own
* expiry, its own "this is my device" -- and nothing about one can be read
* through another.
*
* At most `MAX_ACCOUNTS` in all, all on the same mail server (MA-9), and only
* while both accounts' organizations allow it (`addAccounts`, MA-C).
*/
import type { UpstreamSession } from "./upstream.js";
export const MAX_ACCOUNTS = 5;
/** A session cookie's shape: `id.secret`, both base64url. Anything else is dropped. */
const COOKIE_SHAPE = /^[A-Za-z0-9_-]{8,128}\.[A-Za-z0-9_-]{8,256}$/;
const SEP = "~";
export function parseOthers(value: string | undefined): string[] {
if (!value) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const part of value.split(SEP)) {
if (!COOKIE_SHAPE.test(part) || seen.has(part)) continue;
seen.add(part);
out.push(part);
if (out.length >= MAX_ACCOUNTS - 1) break;
}
return out;
}
export function serializeOthers(cookies: string[]): string {
return cookies.filter((c) => COOKIE_SHAPE.test(c)).slice(0, MAX_ACCOUNTS - 1).join(SEP);
}
/**
* Whether the account behind `upstream` may have other accounts beside it:
* `addAccounts` on its own account's `urn:inbuxa:jmap` capability. A server
* that doesn't say (an older one, or not inbuxa) allows it, as before.
*/
export function mayAddAccounts(upstream: UpstreamSession): boolean {
const primary = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(upstream.accounts ?? {})[0];
const account = primary ? (upstream.accounts?.[primary] as { accountCapabilities?: Record<string, unknown> } | undefined) : undefined;
const inbuxa = account?.accountCapabilities?.["urn:inbuxa:jmap"] as { addAccounts?: unknown } | undefined;
return inbuxa?.addAccounts !== false;
}
/** Why an account can't be added, in words for the person. */
export const ADD_REFUSED: Record<string, string> = {
add_full: `You can have at most ${MAX_ACCOUNTS} accounts open here.`,
add_not_allowed: "Your organization doesn't allow adding other accounts here.",
add_other_server: "That account is on another mail server. Only accounts on this server can be added.",
};
+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.
+484 -7
View File
@@ -41,8 +41,11 @@ import {
revokeAppPassword,
} from "./account.js";
import { imageProxyHandler } from "./imageproxy.js";
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, passwordConfirms, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
import { icsProxyHandler } from "./icsproxy.js";
import { staticHandler } from "./static.js";
import { mailNode, webmailNode } from "./nodes.js";
import { ADD_REFUSED, MAX_ACCOUNTS, mayAddAccounts, parseOthers, serializeOthers } from "./accounts.js";
type Env = { Variables: { session: LiveSession } };
@@ -199,6 +202,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);
@@ -229,7 +235,20 @@ const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req
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);
}
@@ -237,6 +256,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.
*
@@ -263,6 +331,146 @@ function setSessionCookie(c: Context, value: string, remember: boolean) {
});
}
/*
* inbuxa MA-B: the other signed-in accounts, beside the one in front. See
* accounts.ts. Kept across a browser restart only when every account in it
* would be (MA-7).
*/
const OTHERS_COOKIE = `${config.cookieName}_more`;
function setOthersCookie(c: Context, cookies: string[]) {
if (!cookies.length) {
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
return;
}
const remember = cookies.every((cookie) => sessions.resolve(cookie)?.remember === true);
setCookie(c, OTHERS_COOKIE, serializeOthers(cookies), {
httpOnly: true,
sameSite: "Lax",
secure: isSecureRequest(c),
path: cookiePath,
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
});
}
/** The other accounts still signed in, in their order; ended ones are left out. */
function liveOthers(c: Context): { cookie: string; session: LiveSession }[] {
const out: { cookie: string; session: LiveSession }[] = [];
for (const cookie of parseOthers(getCookie(c, OTHERS_COOKIE))) {
const session = sessions.resolve(cookie);
if (session) out.push({ cookie, session });
}
return out;
}
/*
* inbuxa MA-8: what may be asked of a signed-in account that isn't in front.
*
* Its push subscription has to be registered, verified and renewed through
* its own session -- a JMAP push subscription belongs to whoever signs the
* request -- and a notification's Archive and Mark read act on its mail. Those
* four methods, and for Email/set only changes to keywords and mailboxes: the
* browser already holds the session, so this reaches nothing new, but it is
* kept to what the notifications need.
*/
const OTHER_ACCOUNT_METHODS = new Set(["PushSubscription/get", "PushSubscription/set", "Mailbox/get", "Email/set"]);
export function otherAccountCallAllowed(call: unknown): boolean {
if (!Array.isArray(call) || call.length !== 3) return false;
const [method, args] = call as [unknown, unknown, unknown];
if (typeof method !== "string" || !OTHER_ACCOUNT_METHODS.has(method)) return false;
if (typeof args !== "object" || args === null) return false;
if (method !== "Email/set") return true;
const set = args as { create?: unknown; destroy?: unknown; update?: unknown };
if (set.create !== undefined || set.destroy !== undefined) return false;
if (typeof set.update !== "object" || set.update === null) return false;
return Object.values(set.update as Record<string, unknown>).every(
(patch) =>
typeof patch === "object" &&
patch !== null &&
Object.keys(patch).every((k) => k === "keywords" || k === "mailboxIds" || k.startsWith("keywords/") || k.startsWith("mailboxIds/")),
);
}
/** inbuxa MA-8: Inbox unread counts of accounts not in front, briefly kept. */
const UNREAD_CACHE_MS = 60_000;
const unreadCache = new Map<string, { unread: number | null; at: number }>();
/** The Inbox's unread count for one session's account, or null when it has none. */
async function inboxUnread(session: LiveSession): Promise<number | null> {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
if (!accountId) return null;
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
method: "POST",
headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
methodCalls: [["Mailbox/get", { accountId, ids: null, properties: ["role", "unreadEmails"] }, "0"]],
}),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) return null;
const body = (await res.json()) as { methodResponses?: [string, { list?: { role?: string | null; unreadEmails?: number }[] }, string][] };
const inbox = body.methodResponses?.[0]?.[1]?.list?.find((m) => m.role === "inbox");
return typeof inbox?.unreadEmails === "number" ? inbox.unreadEmails : null;
}
/** Whether the account in front may have more beside it, or why not. */
async function addRefusal(c: Context, front: LiveSession): Promise<string | null> {
if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full";
try {
const upstream = await getUpstreamSession(front.id, front.authorization, upstreamFor(front.username));
if (!mayAddAccounts(upstream)) return "add_not_allowed";
} catch {
return "add_not_allowed";
}
return null;
}
/**
* A session just signed in to be added beside the one in front (MA-B). It
* comes to the front and the old front joins the others; or, refused, it is
* ended and the front stays. Returns the refusal, or null.
*/
async function joinAccount(
c: Context,
created: { cookie: string; session: LiveSession },
upstream: Awaited<ReturnType<typeof fetchUpstreamSession>>,
): Promise<string | null> {
const frontCookie = getCookie(c, config.cookieName);
const front = sessions.resolve(frontCookie);
if (!front || !frontCookie) {
// Nobody in front any more: an ordinary sign-in
setSessionCookie(c, created.cookie, created.session.remember);
return null;
}
const others = liveOthers(c);
const end = (code: string | null) => {
sessions.destroy(created.session.id);
forgetUpstreamSession(created.session.id);
return code;
};
if (upstreamFor(created.session.username) !== upstreamFor(front.username)) return end("add_other_server");
// Both organizations must allow it
if (!mayAddAccounts(upstream)) return end("add_not_allowed");
const frontRefusal = await addRefusal(c, front);
if (frontRefusal === "add_not_allowed") return end(frontRefusal);
// Already open: that one comes to the front instead of a second copy
if (created.session.account === front.account) return end(null);
const existing = others.find((o) => o.session.account === created.session.account);
if (existing) {
end(null);
setSessionCookie(c, existing.cookie, existing.session.remember);
setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== existing).map((o) => o.cookie)]);
return null;
}
if (1 + others.length >= MAX_ACCOUNTS) return end("add_full");
setSessionCookie(c, created.cookie, created.session.remember);
setOthersCookie(c, [frontCookie, ...others.map((o) => o.cookie)]);
return null;
}
function upstreamFailure(c: Context, err: unknown) {
if (err instanceof UpstreamError) {
return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502);
@@ -316,11 +524,104 @@ 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);
// inbuxa MA-B: another account beside the one in front, if it may have one
const front = c.req.query("add") === "1" ? sessions.resolve(getCookie(c, config.cookieName)) : null;
if (front) {
const refused = await addRefusal(c, front);
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
}
try {
const { location, state } = await startSignIn({
username,
base: upstreamFor(username),
remember: c.req.query("remember") === "1",
adding: front !== null,
});
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),
});
if (result.adding) {
const refused = await joinAccount(c, { cookie, session }, upstream);
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
return c.redirect(`${basePath}/`, 302);
}
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);
@@ -329,7 +630,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
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 };
let body: { username?: string; password?: string; totp?: string; remember?: boolean; add?: boolean };
try {
body = await c.req.json();
} catch {
@@ -378,7 +679,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
{
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,
);
@@ -392,11 +693,17 @@ export function createApp(basePath = config.basePath): Hono<Env> {
userAgent: c.req.header("user-agent") ?? "",
ip,
});
// inbuxa MA-B: beside the account in front, when that's what was asked
if (body.add && sessions.resolve(getCookie(c, config.cookieName))) {
const refused = await joinAccount(c, { cookie, session }, upstream);
if (refused) return c.json({ error: refused, message: ADD_REFUSED[refused] }, 403);
return c.json({ ok: true, added: true });
}
setSessionCookie(c, cookie, session.remember);
// 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) {
@@ -421,7 +728,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,
);
@@ -462,10 +769,132 @@ export function createApp(basePath = config.basePath): Hono<Env> {
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
}
// inbuxa MA-B: only this account ends; the next one comes to the front
const [next, ...rest] = liveOthers(c);
if (next) {
setSessionCookie(c, next.cookie, next.session.remember);
setOthersCookie(c, rest.map((o) => o.cookie));
return c.json({ ok: true, next: true });
}
deleteCookie(c, config.cookieName, { path: cookiePath });
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
return c.json({ ok: true });
});
/* inbuxa MA-B: every account signed in here ends. */
api.post("/auth/logout-all", async (c) => {
const front = sessions.resolve(getCookie(c, config.cookieName));
for (const session of [front, ...liveOthers(c).map((o) => o.session)]) {
if (!session) continue;
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
}
deleteCookie(c, config.cookieName, { path: cookiePath });
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
return c.json({ ok: true });
});
/* inbuxa MA-B: the accounts signed in here, the one in front first, and whether one more may be added. */
api.get("/auth/accounts", requireSession, async (c) => {
const front = c.get("session");
const others = liveOthers(c);
if (others.length !== parseOthers(getCookie(c, OTHERS_COOKIE)).length) {
setOthersCookie(c, others.map((o) => o.cookie));
}
const canAdd = (await addRefusal(c, front)) === null;
// inbuxa MA-8: each one's mail account, which its push subscription and
// a notification's buttons need
const accounts = await Promise.all(
[front, ...others.map((o) => o.session)].map(async (s, i) => {
let mailAccountId: string | null = null;
try {
const upstream = await getUpstreamSession(s.id, s.authorization, upstreamFor(s.username));
mailAccountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? null;
} catch {
/* unknown for now: push for it waits for the next start */
}
return { id: s.id, username: s.username, front: i === 0, mailAccountId };
}),
);
return c.json({ accounts, canAdd, max: MAX_ACCOUNTS });
});
/*
* inbuxa MA-8: the Inbox unread count of each account not in front, asked
* through that account's own session, so the menu can say where new mail
* is. A minute's cache per account: the web app asks every few minutes, and
* several tabs may ask at once.
*/
api.get("/auth/accounts/unread", requireSession, async (c) => {
const answers = await Promise.all(
liveOthers(c).map(async ({ cookie, session }) => {
const cached = unreadCache.get(session.id);
if (cached && Date.now() - cached.at < UNREAD_CACHE_MS) return { id: session.id, unread: cached.unread };
try {
let live: LiveSession | null = session;
if (live.tokens && needsRefresh(live.tokens)) live = await refreshSession(cookie, live);
if (!live) return { id: session.id, unread: null };
const unread = await inboxUnread(live);
unreadCache.set(session.id, { unread, at: Date.now() });
return { id: session.id, unread };
} catch {
return { id: session.id, unread: null };
}
}),
);
return c.json({ accounts: answers });
});
/* inbuxa MA-8: a narrow JMAP route to a signed-in account not in front; see OTHER_ACCOUNT_METHODS. */
api.post("/auth/accounts/:id/jmap", requireSession, async (c) => {
const other = liveOthers(c).find((o) => o.session.id === c.req.param("id"));
if (!other) return c.json({ error: "not_found" }, 404);
let body: { using?: unknown; methodCalls?: unknown };
try {
body = await c.req.json();
} catch {
return c.json({ error: "bad_request" }, 400);
}
const calls = body.methodCalls;
if (!Array.isArray(calls) || calls.length === 0 || calls.length > 16 || !calls.every(otherAccountCallAllowed)) {
return c.json({ error: "forbidden", message: "Only push subscriptions, mailboxes and marking mail can be reached in another account." }, 403);
}
const using = Array.isArray(body.using) ? body.using.filter((u): u is string => typeof u === "string") : [];
let session: LiveSession | null = other.session;
if (session.tokens && needsRefresh(session.tokens)) session = await refreshSession(other.cookie, session);
if (!session) return c.json({ error: "unauthenticated" }, 401);
try {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
method: "POST",
headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ using, methodCalls: calls }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) return c.json({ error: "unauthenticated" }, 401);
return c.json(await res.json(), res.ok ? 200 : 502);
} catch (err) {
return upstreamFailure(c, err);
}
});
/* inbuxa MA-B: bring another signed-in account to the front. */
api.post("/auth/accounts/:id/front", requireSession, async (c) => {
const frontCookie = getCookie(c, config.cookieName)!;
const others = liveOthers(c);
const chosen = others.find((o) => o.session.id === c.req.param("id"));
if (!chosen) return c.json({ error: "not_found" }, 404);
setSessionCookie(c, chosen.cookie, chosen.session.remember);
setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== chosen).map((o) => o.cookie)]);
return c.json({ ok: true });
});
/* ihasmail-inbuxa: which webmail node this is, and which mail node it talks to (nodes.ts). */
api.get("/about/nodes", requireSession, async (c) => {
const session = c.get("session");
return c.json({ webmail: webmailNode(), mailServer: await mailNode(upstreamFor(session.username)) });
});
api.get("/auth/sessions", requireSession, (c) => {
const session = c.get("session");
return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
@@ -486,7 +915,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));
@@ -535,6 +964,14 @@ 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();
@@ -625,6 +1062,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) });
@@ -663,6 +1110,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);
@@ -880,7 +1328,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");
@@ -957,6 +1405,15 @@ async function readJson<T>(c: Context): Promise<T | null> {
* server.
*/
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
if (session.tokens) {
// Holding no password, the only judge is the server, asked on its sign-in
// endpoint since it takes no password over JMAP.
try {
return await passwordConfirms({ base: upstreamFor(session.username), username: session.username, password: candidate });
} 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;
@@ -974,6 +1431,24 @@ async function confirmsPassword(session: LiveSession, candidate: string): Promis
}
}
/**
* 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.
@@ -999,6 +1474,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,
/**
+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");
/**
+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 () => {
+43 -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,16 @@ 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. inbuxa-webmail 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/inbuxa-webmail"),
/**
* ihasmail-inbuxa: this webmail's own name, shown in Settings > About next
* to the mail node it talks to. Set per host by the deploy; empty means the
* container's hostname. See nodes.ts.
*/
nodeName: env("NODE_NAME", ""),
host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080),
/**
@@ -300,12 +321,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 +335,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 +392,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");
+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}`);
});
+5 -6
View File
@@ -18,8 +18,8 @@ const PORT = 18799;
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.MOCK_NO_REGISTRY = "1"; // a server without urn:inbuxa:jmap:registry
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 () => {
+1 -1
View File
@@ -5,7 +5,7 @@ export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../we
export const PORT = Number(process.env.MOCK_PORT ?? 8788);
/**
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
* Omit `urn:inbuxa:jmap:registry` 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.
+11 -6
View File
@@ -40,10 +40,10 @@ export function putBlob(data: Buffer | string, type: string): string {
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", "hello@stalw.art"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
["inbuxa", "hello@inbuxa.org"], ["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",
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to inbuxa!", "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",
];
@@ -122,7 +122,11 @@ export function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [
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: [],
// `htmlBody` is derived (RFC 8621 4.1.4): a message with no HTML
// alternative still gets one, holding the text/plain part. Checked against
// Stalwart 0.16.21 on 2026-09-10 -- see hasHtmlAlternative() in the client,
// which reads the part's type rather than trusting this list to be empty.
htmlBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
attachments: [],
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
bodyStructure: {
@@ -169,8 +173,8 @@ export const STYLED_MARKETING_HTML = `<html><head><style>
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 text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the 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 mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://inbuxa.org">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[] = [];
@@ -192,7 +196,8 @@ export function addEmail(o: { from: [string, string]; to?: string; subject: stri
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 }] : [],
// No HTML alternative means `htmlBody` names the text part, not nothing. See addSignedEmail.
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 }] : [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", 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] },
+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[] = [
+34 -8
View File
@@ -1,7 +1,7 @@
/**
* A tiny in-memory JMAP server that mimics the subset of Stalwart that ihasmail
* uses. For local development and demos only: `npm run mock` then point the
* server at it with STALWART_URL=http://127.0.0.1:8788 (user: demo / pass: demo).
* server at it with MAIL_SERVER_URL=http://127.0.0.1:8788 (user: demo / pass: demo).
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
@@ -14,6 +14,7 @@ import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from
import { handlers } from "./handlers.js";
export { account } from "./config.js";
import { checkOtp } from "./auth.js";
import { basicRefused, checkBearer, handleOAuth } from "./oauth.js";
import { sseClients, broadcast } from "./events.js";
/* ---------- http ---------- */
@@ -22,9 +23,26 @@ function unauthorized(res: ServerResponse) {
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" }));
}
/*
* inbuxa MA-B: an optional second user, so the account switcher has two real
* accounts to move between. It signs in with a password only, and reads the
* same mailbox: what the tests look at is who the session says it is.
*/
const SECOND_USER = process.env.MOCK_SECOND_USER;
const SECOND_PASS = process.env.MOCK_SECOND_PASS;
/** Who a Basic header signs in as, when it is the second user. */
function secondUser(req: IncomingMessage): boolean {
const h = req.headers.authorization ?? "";
if (!SECOND_USER || !h.startsWith("Basic ")) return false;
return Buffer.from(h.slice(6), "base64").toString() === `${SECOND_USER}:${SECOND_PASS}`;
}
function checkAuth(req: IncomingMessage): boolean {
const h = req.headers.authorization ?? "";
if (!h.startsWith("Basic ")) return false;
if (checkBearer(h)) return true;
if (secondUser(req)) return true;
if (!h.startsWith("Basic ") || basicRefused) return false;
const raw = Buffer.from(h.slice(6), "base64").toString();
const sep = raw.indexOf(":");
if (sep < 0) return false;
@@ -58,8 +76,8 @@ const session = () => ({
* the only way this stays honest about what can be inferred from a
* capability, which is nothing.
*/
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "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": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "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": {}, ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": ACCOUNT }) },
username: USER,
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
@@ -77,10 +95,18 @@ const session = () => ({
/** Exported so tests can drive the mock in-process and shut it down. */
export const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
if (await handleOAuth(req, res, url)) return;
if (!checkAuth(req)) return unauthorized(res);
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify(session()));
const answer = session() as ReturnType<typeof session> & { username: string };
if (secondUser(req)) answer.username = SECOND_USER!;
// MA-C: an organization that doesn't allow adding accounts
if (process.env.MOCK_NO_ADD_ACCOUNTS === "1") {
const own = answer.accounts[ACCOUNT] as { accountCapabilities: Record<string, unknown> };
own.accountCapabilities = { ...own.accountCapabilities, "urn:inbuxa:jmap": { addAccounts: false } };
}
return res.end(JSON.stringify(answer));
}
// The account info endpoint; the only place a server reports its edition.
if (url.pathname === "/api/account" && req.method === "GET") {
@@ -100,7 +126,7 @@ export const server = createServer(async (req, res) => {
// call that wanted it - which is why an over-eager `using` is so damaging.
// Stalwart decides this by parsing the urn, not by looking it up in the
// session, so a capability it hands out per-account is still usable here:
// `urn:stalwart:jmap` never appears in the session-level capabilities and
// `urn:inbuxa:jmap:registry` never appears in the session-level capabilities and
// the registry calls that name it work all the same.
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
const unknown = (body.using ?? []).find((u) => !known.has(u));
@@ -201,8 +227,8 @@ export const server = createServer(async (req, res) => {
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
}).listen(PORT, "127.0.0.1", () => {
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`);
console.log(`[mock-server] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
console.log(`[mock-server] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`);
});
// Periodically inject a new inbox email to demo push
+175
View File
@@ -0,0 +1,175 @@
/**
* 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}`;
/**
* Whether JMAP refuses Basic, as INBUXA's server does outside DAV (contract
* C-23). Off by default, since the mock also serves password sign-in.
*/
export let basicRefused = false;
/** 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; },
refuseBasic(on: boolean) { basicRefused = on; },
reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; basicRefused = false; },
};
/** 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 readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks)));
});
}
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 === "/api/auth" && req.method === "POST") {
// The server's sign-in page posts here; the mock answers for the demo user.
let body: Record<string, unknown>;
try {
body = JSON.parse((await readBody(req)).toString()) as Record<string, unknown>;
} catch {
json(res, 400, { error: "invalid_request" });
return true;
}
const redirectUri = typeof body.redirectUri === "string" ? body.redirectUri : "";
if (body.type !== "authCode" || body.clientId !== OAUTH_CLIENT_ID || !redirectUri || body.codeChallengeMethod !== "S256") {
json(res, 400, { error: "invalid_request" });
return true;
}
const secret = typeof body.accountSecret === "string" ? body.accountSecret : "";
const passwordOk = body.accountName === USER && (secret === account.password || account.appPasswords.some((a) => a.secret === secret));
if (!passwordOk) {
json(res, 200, { type: "failure" });
return true;
}
if (account.otpUrl && secret === account.password && !body.mfaToken) {
json(res, 200, { type: "mfaRequired" });
return true;
}
const code = randomBytes(16).toString("hex");
codes.set(code, { challenge: String(body.codeChallenge ?? ""), redirectUri, issuedAt: Date.now() });
json(res, 200, { type: "authenticated", client_code: code, iss: base() });
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;
+49
View File
@@ -0,0 +1,49 @@
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { clearNodeCache, mailNode, ptrName } from "./nodes.js";
/**
* About names the mail node by the PTR of the address the server's name
* resolves to from the webmail -- the local node, where a host entry pins it.
*/
beforeEach(() => clearNodeCache());
const lookup = (address: string) => async () => ({ address });
test("the node is named by its address's PTR, without the trailing dot", async () => {
const n = await mailNode("https://mail.example.com", lookup("192.0.2.2"), async () => ["mx2.example.com."]);
assert.deepEqual(n, { host: "mail.example.com", address: "192.0.2.2", name: "mx2.example.com" });
});
test("an address without a PTR still shows the address", async () => {
const n = await mailNode("https://mail.example.com", lookup("192.0.2.3"), async () => { throw new Error("ENOTFOUND"); });
assert.deepEqual(n, { host: "mail.example.com", address: "192.0.2.3", name: null });
});
test("a name that doesn't resolve says so instead of failing", async () => {
let reversed = false;
const n = await mailNode("https://mail.example.com", async () => { throw new Error("ENOTFOUND"); }, async () => { reversed = true; return []; });
assert.deepEqual(n, { host: "mail.example.com", address: null, name: null });
assert.equal(reversed, false);
});
test("the answer is cached for a minute, then looked up again", async () => {
let calls = 0;
const count = async () => { calls++; return { address: "192.0.2.1" }; };
const rev = async () => ["mail.example.com"];
await mailNode("https://mail.example.com", count, rev, 0);
await mailNode("https://mail.example.com", count, rev, 59_000);
assert.equal(calls, 1);
await mailNode("https://mail.example.com", count, rev, 61_000);
assert.equal(calls, 2);
});
test("the PTR name is built for IPv4 and IPv6 alike", () => {
assert.equal(ptrName("192.0.2.52"), "52.2.0.192.in-addr.arpa");
assert.equal(
ptrName("2001:db8::25"),
"5.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa",
);
assert.equal(ptrName("::1"), "1" + ".0".repeat(31) + ".ip6.arpa");
});
+85
View File
@@ -0,0 +1,85 @@
/**
* ihasmail-inbuxa: which webmail node answered, and which inbuxa node it talks
* to -- shown in Settings > About, for troubleshooting a cluster.
*
* Not for upstream ihasmail: it only makes sense where one webmail runs per
* host, each pinned to its own host's mail node.
*
* The webmail node is NODE_NAME, set per host by the deploy; without it, the
* container's hostname, which is at least distinct. The mail node is worked
* out from the address the server's name resolves to *here*, where the host
* entry pins it to the local node, named by that address's PTR record (every
* node has one: mail/mx2/mx3). The server offers its node name only to
* administrators, so asking it would leave everyone else with nothing.
*/
import { lookup as dnsLookup, resolvePtr } from "node:dns/promises";
import { isIPv4 } from "node:net";
import { hostname } from "node:os";
import { config } from "./config.js";
export interface MailNode {
/** The server name the webmail connects to, from its configured URL. */
host: string;
/** What that name resolves to from this container, or null if it doesn't. */
address: string | null;
/** The address's PTR name: the node's own name. Null without a PTR. */
name: string | null;
}
export interface Nodes {
webmail: string;
mailServer: MailNode;
}
type Lookup = (host: string) => Promise<{ address: string }>;
type Reverse = (address: string) => Promise<string[]>;
/**
* The reverse-lookup name for an address: 1.2.0.192.in-addr.arpa, or the
* nibble form under ip6.arpa. Asked for directly, because `dns.reverse` came
* back empty in the image while the resolver answered the PTR (2026-09-26).
*/
export function ptrName(address: string): string {
if (isIPv4(address)) return `${address.split(".").reverse().join(".")}.in-addr.arpa`;
const [head = "", tail = ""] = address.split("::");
const groups = (part: string) => (part ? part.split(":") : []);
const h = groups(head), t = groups(tail);
const full = [...h, ...Array(8 - h.length - t.length).fill("0"), ...t];
return `${full.map((g) => g.padStart(4, "0")).join("").split("").reverse().join(".")}.ip6.arpa`;
}
const dnsReverse: Reverse = (address) => resolvePtr(ptrName(address));
const CACHE_MS = 60_000;
const cache = new Map<string, { node: MailNode; at: number }>();
export function webmailNode(): string {
return config.nodeName || hostname();
}
export async function mailNode(base: string, lookup: Lookup = dnsLookup, reverse: Reverse = dnsReverse, now = Date.now()): Promise<MailNode> {
const host = new URL(base).hostname;
const hit = cache.get(host);
if (hit && now - hit.at < CACHE_MS) return hit.node;
let address: string | null = null;
let name: string | null = null;
try {
address = (await lookup(host)).address;
} catch {
/* unresolvable: say so rather than fail the page */
}
if (address) {
try {
name = (await reverse(address))[0]?.replace(/\.$/, "") ?? null;
} catch {
/* no PTR */
}
}
const node = { host, address, name };
cache.set(host, { node, at: now });
return node;
}
export function clearNodeCache(): void {
cache.clear();
}
+239
View File
@@ -0,0 +1,239 @@
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();
// As INBUXA's server does: no password over JMAP (contract C-23).
oauthMock.refuseBasic(true);
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-/);
});
test("inbuxa MA-B: adding an account asks the server's page to sign in again", async () => {
// With nobody in front, add=1 is an ordinary sign-in
let res = await call("/api/auth/oauth/[email protected]&add=1");
assert.equal(new URL(res.headers.get("location")!).searchParams.has("prompt"), false);
await signIn();
res = await call("/api/auth/oauth/[email protected]&add=1");
assert.equal(res.status, 302);
const signInPage = new URL(res.headers.get("location")!);
assert.equal(signInPage.searchParams.get("prompt"), "login", "the server's page must not reuse the first sign-in");
// The mock's page signs the same account in again: it stays one account
const approved = await fetch(signInPage, { redirect: "manual" });
const back = new URL(approved.headers.get("location")!);
res = await call(`/api/auth/callback${back.search}`);
assert.equal(res.headers.get("location"), "/");
const list = await jsonOf(await call("/api/auth/accounts"));
assert.deepEqual(list.accounts.map((a: { username: string }) => a.username), ["[email protected]"]);
});
+245
View File
@@ -0,0 +1,245 @@
/**
* 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;
/** MA-B: signing in a second account beside the one in front. */
adding: 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; adding?: 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, adding: Boolean(params.adding), 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);
// MA-B: ask again, rather than let the server's page reuse the sign-in of
// the account already in front
if (params.adding) url.searchParams.set("prompt", "login");
return { location: url.toString(), state };
}
/**
* Whether `password` is the account's password, for a session that holds a
* token and so has no password to compare with.
*
* Asked of the server's sign-in endpoint, the one its own sign-in page posts
* to, because the server takes no password over JMAP (contract C-23). The
* request is this client's, to its registered redirect URI, so it passes the
* same checks a real sign-in does. A code it issues can never be exchanged:
* the PKCE verifier behind its challenge is thrown away here.
*
* "Two-factor code needed" counts as confirmed: the server says so only once
* the password has matched.
*/
export async function passwordConfirms(params: { base: string; username: string; password: string }): Promise<boolean> {
const res = await fetch(absoluteUpstream("/api/auth", params.base), {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
type: "authCode",
accountName: params.username,
accountSecret: params.password,
clientId: config.oauthClientId,
redirectUri: redirectUri(),
codeChallenge: challengeOf(randomToken(48)),
codeChallengeMethod: "S256",
}),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) throw new UpstreamError(`Password check failed (${res.status})`, 502);
const answer = (await res.json()) as { type?: string };
return answer.type === "authenticated" || answer.type === "mfaRequired";
}
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; adding: 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, adding: p.adding };
}
/**
* 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();
}
+14 -11
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);
@@ -168,7 +171,7 @@ test("a new subscription clears what this installation left behind, and only tha
}) as typeof fetch;
try {
// The installation's prefix, learned the way the server makes it: from its first create.
push.prepare("[email protected]", "a", "Basic p");
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 ?? "");
@@ -176,7 +179,7 @@ test("a new subscription clears what this installation left behind, and only tha
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
calls.length = 0;
push.prepare("[email protected]", "a", "Basic r");
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");
+18 -8
View File
@@ -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),
});
@@ -159,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";
@@ -174,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;
}
@@ -183,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); });
+1 -1
View File
@@ -15,7 +15,7 @@ const PORT = 18813;
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-request-limits";
const mock = await import("./mock/index.js");
+39 -11
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,7 +11,7 @@ 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. */
@@ -28,8 +29,10 @@ export interface LiveSession {
username: string;
/** See `accountKey`. */
account: string;
/** Basic Authorization header value for upstream calls. */
/** 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;
@@ -71,7 +74,9 @@ export interface CreateSessionParams {
username: string;
/** From `accountKey`; defaults to the lower-cased username. */
account?: string;
password: string;
/** Exactly one of `password` and `tokens`. */
password?: string;
tokens?: TokenSet;
remember: boolean;
userAgent: string;
ip: string;
@@ -107,6 +112,8 @@ 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;
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
destroyAllForUser(account: string, exceptId?: string): number;
@@ -115,6 +122,15 @@ export interface SessionBackend {
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;
@@ -194,7 +210,7 @@ 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,
@@ -207,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). */
@@ -229,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;
}
@@ -242,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);
}
/**
@@ -255,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;
@@ -264,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;
}
@@ -295,12 +319,16 @@ export class SessionStore implements SessionBackend {
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,
account: accountOf(s),
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
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")));
+1 -1
View File
@@ -29,7 +29,7 @@ writeFileSync(join(root, "sw.js"), "/* worker */\n");
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
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 app = createApp();
+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) => {
+8 -8
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 {
@@ -178,14 +178,14 @@ export function forgetUpstreamSession(sessionId: string): void {
/* Account locale */
/* ------------------------------------------------------------------ */
const STALWART_CAP = "urn:stalwart:jmap";
const STALWART_CAP = "urn:inbuxa:jmap:registry";
const JMAP_CORE = "urn:ietf:params:jmap:core";
/**
* Whether this server has Stalwart's JMAP registry — the `x:` objects that
* carry credentials, account settings and the newer FileNode shape.
*
* `urn:stalwart:jmap` is the marker, but **not** in the session-level
* `urn:inbuxa:jmap:registry` is the marker, but **not** in the session-level
* `capabilities`, which is where a JMAP client would naturally look. Stalwart
* builds that list from a fixed set that has never included this capability;
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
@@ -427,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.
@@ -435,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.
@@ -443,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.
*/
+4 -4
View File
@@ -19,11 +19,11 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<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="icon" href="/favicon.ico?v=2026-09-27a" sizes="any" />
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png?v=2026-09-27a" />
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png?v=2026-09-27a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>ihasmail</title>
<title>inbuxa</title>
</head>
<body>
<div id="root"></div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+6 -6
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",
@@ -51,17 +51,17 @@
"theme_color": "#0f766e",
"icons": [
{
"src": "img/icon-192.png",
"src": "img/icon-192.png?v=2026-09-27a",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "img/icon-512.png",
"src": "img/icon-512.png?v=2026-09-27a",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "img/icon-maskable.png",
"src": "img/icon-maskable.png?v=2026-09-27a",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
+88 -12
View File
@@ -18,7 +18,9 @@ const VERSION = "ihasmail-v2";
* eventually would.
*/
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
// The brand images' version; the same value as BRAND_V in src/lib/brand.ts.
const BRAND_V = "2026-09-27a";
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png?v=${BRAND_V}`, `${BASE}/img/icon-192.png?v=${BRAND_V}`, `${BASE}/favicon.ico?v=${BRAND_V}`];
/*
* Only the app page may be kept as the app page.
@@ -384,8 +386,11 @@ async function readFacts() {
* rather than swallowed. A tap that silently does nothing is the failure worth
* avoiding here: the reader has already put the phone down.
*/
async function jmap(methodCalls) {
const res = await fetch(`${BASE}/api/jmap`, {
async function jmap(methodCalls, sessionId) {
// inbuxa MA-8: an account not in front is reached through its own session,
// on the webmail server's narrow route for it
const path = sessionId ? `${BASE}/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap` : `${BASE}/api/jmap`;
const res = await fetch(path, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
@@ -454,6 +459,15 @@ self.addEventListener("push", (event) => {
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
event.waitUntil((async () => {
const facts = await readFacts();
/*
* inbuxa MA-8: whose mail this is. The payload names its account; one that
* isn't the account in front is one of the others signed in here, or one
* that has been signed out since (said plainly, with nothing to act on).
*/
const forAccount = data && data.accountId && facts && data.accountId !== facts.accountId ? data.accountId : null;
const other = forAccount ? (facts.others || []).find((o) => o.accountId === forAccount) || null : null;
if (forAccount) return showOtherAccount(emails, facts, other);
/*
* 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
@@ -462,7 +476,6 @@ self.addEventListener("push", (event) => {
*/
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)" };
/*
* Mark the app icon, without claiming a number.
@@ -482,7 +495,7 @@ self.addEventListener("push", (event) => {
// 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` },
icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`, badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
});
return;
}
@@ -492,16 +505,15 @@ self.addEventListener("push", (event) => {
const { title, body, preview } = textOf(email, strings);
await self.registration.showNotification(title, {
body: preview ? `${body}\n${preview}` : body,
icon: `${BASE}/img/icon-192.png`,
badge: `${BASE}/img/favicon-64.png`,
icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
tag: `ihasmail-${email.id || body}`,
// Only where there is a message to act on: a payload without an id can
// be shown but not archived, and a button that cannot work should not
// be drawn.
actions: email.id ? actionsFor(facts) : [],
data: {
// 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`,
url: messageUrl(facts && facts.inboxId, email),
id: email.id || null,
title,
accountId: facts?.accountId ?? null,
@@ -513,6 +525,70 @@ self.addEventListener("push", (event) => {
})());
});
/*
* inbuxa MA-8: new mail for a signed-in account that isn't in front.
*
* Shown even while a tab is focused: that tab's own stream only carries the
* account in front, so nothing else would tell. The account's address is the
* title, so it can't be taken for the front account's mail; the tag carries
* the account, so two accounts' notifications don't replace each other; the
* buttons act through that account's session; and opening it brings that
* account forward before showing the message.
*/
async function showOtherAccount(emails, facts, other) {
const strings = facts.strings;
const icon = `${BASE}/img/icon-192.png?v=${BRAND_V}`;
const badge = `${BASE}/img/favicon-64.png?v=${BRAND_V}`;
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
if (!other || !emails.length) {
// Signed out since, or nothing to show: say only what is true
await self.registration.showNotification(other ? other.username : strings.newMail, {
body: other ? strings.newMail : undefined,
icon, badge,
tag: `ihasmail-other-${other ? other.accountId : "unknown"}`,
data: { url: other ? openUrl(other, `${BASE}/mail`) : `${BASE}/mail` },
});
return;
}
for (const email of emails.slice(0, 5)) {
const { title, body, preview } = textOf(email, strings);
const at = messageUrl(other.inboxId, email);
await self.registration.showNotification(other.username, {
body: `${title}: ${body}${preview ? `\n${preview}` : ""}`,
icon, badge,
tag: `ihasmail-${other.accountId}-${email.id || body}`,
actions: email.id ? actionsFor({ ...facts, archiveId: other.archiveId }) : [],
data: {
url: openUrl(other, at),
id: email.id || null,
title: other.username,
accountId: other.accountId,
archiveId: other.archiveId,
sessionId: other.sessionId,
failed: strings.failed ?? null,
},
});
}
}
/** Where opening a notification for an account not in front goes: it comes forward first. */
/**
* Where a notification opens. The route names a mailbox by id and then a
* conversation, and `m` the message in it. It used to say `inbox` where the id
* goes, which the app reads as a folder that no longer exists, so every click
* landed on the inbox list with "That folder no longer exists" instead of the
* message. Without an inbox id from the briefing, the inbox is the honest
* landing.
*/
function messageUrl(inboxId, email) {
if (!inboxId || !email.id || !email.threadId) return `${BASE}/mail`;
return `${BASE}/mail/${encodeURIComponent(inboxId)}/${encodeURIComponent(email.threadId)}?m=${encodeURIComponent(email.id)}`;
}
function openUrl(other, next) {
return `${BASE}/?account=${encodeURIComponent(other.sessionId)}&next=${encodeURIComponent(next)}`;
}
/*
* Do what the button said, without opening anything.
*
@@ -533,12 +609,12 @@ async function runAction(action, data) {
: { "keywords/$seen": true };
try {
if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]);
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]], data.sessionId || null);
} catch {
await self.registration.showNotification(data.title || "ihasmail", {
body: data.failed || "Could not do that — open ihasmail and try again",
icon: `${BASE}/img/icon-192.png`,
badge: `${BASE}/img/favicon-64.png`,
icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
tag: `ihasmail-failed-${id}`,
data: { url: data.url },
});
+79 -19
View File
@@ -1,7 +1,7 @@
import { Fragment, lazy, Suspense, useEffect, useState } from "react";
import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react";
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import { useSession, useViewingDelegation } from "@/store/session";
import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail";
import { scheduleSupported, useScheduled } from "@/store/scheduled";
import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
@@ -19,7 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
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 { armSettingsSync, loadRemoteSettings, loadSettingsOnce, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
@@ -119,6 +119,7 @@ export function App() {
function AuthedApp() {
const accountId = useSession((s) => s.accountId);
const viewing = useSession((s) => s.viewing);
const [location] = useLocation();
/*
@@ -140,22 +141,22 @@ function AuthedApp() {
* Once per account, not once per mount: this subtree is keyed on the
* language version, so picking a language throws it away and builds it
* again. Re-reading the settings file there would apply a copy written
* before the change and undo it.
* before the change and undo it. And the load is not this mount's to
* cancel: the settings file choosing a language remounts the tree midway
* through it, and a load cut off there never armed the pushes, so nothing
* changed afterwards was saved (Gitea issue #23). `loadSettingsOnce` runs
* it to the end and lets every mount wait on it.
*/
const [ready, setReady] = useState(PAINTED_FROM_CACHE);
useEffect(() => {
if (settingsAlreadyLoadedFor(accountId)) {
setReady(true);
return;
}
let canceled = false;
void (async () => {
void loadSettingsOnce(accountId, async (isCurrent) => {
/* Before the account's own settings, so both the seeding below and the
enforcement inside `hydrate` have something to apply. */
await loadSettingsPolicy();
if (canceled) return;
if (!isCurrent()) return;
const remote = await loadRemoteSettings();
if (canceled) return;
if (!isCurrent()) return;
if (remote) useSettings.getState().hydrate(remote);
// No settings file: this account has never had settings of its own, so
// the installation's defaults are what it starts on rather than
@@ -177,15 +178,16 @@ function AuthedApp() {
// The catalog for whatever language that turned out to be. Hydrating
// asks for it; this is waiting for the answer.
await whenLanguageReady();
if (canceled) return;
setReady(true);
if (!isCurrent()) return;
// Pushes were held back until now so they could not race the load. A
// change made while it was in flight was kept, and goes out here.
armSettingsSync();
// No file yet — seed one from what this browser has, so the next device
// to sign in starts from these rather than from the defaults.
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
})();
}).then(() => {
if (!canceled) setReady(true);
});
return () => {
canceled = true;
};
@@ -230,6 +232,9 @@ function AuthedApp() {
timer = null;
for (const [a, types] of pending) {
if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types);
// inbuxa AL-7: the reader's own mail, while a delegated account
// is in view, is still announced
if (a === ownAccountAway() && types.has("Email")) void notifyOwnWhileAway();
if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types);
if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types);
if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types);
@@ -253,16 +258,67 @@ function AuthedApp() {
};
}, [accountId]);
/*
* inbuxa AL-7: a delegated account, the whole of it, when it comes into
* view, and the reader's own when it goes back. Push carries nothing for an account only
* shared with the reader, so while one is open it is polled.
*/
const viewedOnce = useRef(false);
useEffect(() => {
if (!viewedOnce.current) {
viewedOnce.current = true;
if (!viewing) return;
}
const mail = useMail.getState();
void mail.loadMailboxes();
void mail.loadIdentities();
// The whole account follows: calendar, contacts and files too
void useCalendar.getState().init();
void useContacts.getState().init();
void useFiles.getState().init();
if (!viewing) return;
const poll = window.setInterval(() => {
if (document.visibilityState === "visible") {
void useMail.getState().applyChanges(new Set(["Email", "Mailbox"]));
}
}, 60_000);
return () => window.clearInterval(poll);
}, [viewing]);
// inbuxa AL-7: a delegation given or taken away while the app is open shows
// up when the reader comes back to it, without signing in again
useEffect(() => {
let last = 0;
const onVisible = () => {
if (document.visibilityState !== "visible" || Date.now() - last < 60_000) return;
last = Date.now();
void useSession.getState().refresh();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, []);
const delegationEnded = useSession((s) => s.delegationEnded);
useEffect(() => {
if (!delegationEnded) return;
toast.show(t("You no longer have access to {name}. Back to your own mail.", { name: delegationEnded }));
useSession.getState().clearDelegationEnded();
}, [delegationEnded]);
// Unread badge in title/favicon
const inboxUnread = useMail((s) => {
const id = s.roleId("inbox");
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
});
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
// inbuxa AL-7: a locked account in view is named, with a padlock, in the
// tab; a shared or group mailbox (MA-A) is named without one
const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
const lockedInView = useViewingDelegation()?.kind === "lock";
useEffect(() => {
setBaseTitle(appName);
setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName);
setUnreadBadge(inboxUnread);
}, [inboxUnread, appName]);
}, [inboxUnread, appName, viewingName, lockedInView]);
/*
* Leave the service worker its briefing.
@@ -274,10 +330,14 @@ function AuthedApp() {
* See lib/swFacts.ts.
*/
const archiveId = useMail((s) => s.roleId("archive"));
const inboxId = useMail((s) => s.roleId("inbox"));
const languageVersion = useLanguageVersion();
useEffect(() => {
void publishWorkerFacts(accountId, archiveId);
}, [accountId, archiveId, languageVersion]);
// inbuxa AL-7: the worker acts on the reader's own mail; while a
// delegated account is in view, the archive folder here is its
if (viewing) return;
void publishWorkerFacts(accountId, archiveId, inboxId);
}, [accountId, archiveId, inboxId, languageVersion, viewing]);
// Request notification permission lazily when enabled
const notif = useSettings((s) => s.settings.desktopNotifications);
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { INBUXA_CAP, legacyProtocolsPartlyOff } from "../client";
import { parseTenantLegacy } from "@/lib/admin/adminLegacyProtocols";
import type { JmapSession } from "../types";
/**
* INBUXA can switch IMAP, POP3 and ManageSieve off one at a time. The session
* lists what is still allowed (`legacyAllowed`); the webmail names what isn't,
* only while some but not all are off, and never from a server that doesn't
* say.
*/
describe("legacyProtocolsPartlyOff", () => {
const session = (cap: Record<string, unknown> | undefined) =>
({
accounts: { a: { name: "[email protected]", accountCapabilities: cap ? { [INBUXA_CAP]: cap } : {} } },
}) as unknown as JmapSession;
it("names what is off when only some are", () => {
const s = session({ legacyProtocols: "enabled", legacyAllowed: ["imap", "manageSieve", "submission"] });
expect(legacyProtocolsPartlyOff(s, "a")).toEqual(["POP3"]);
const two = session({ legacyProtocols: "enabled", legacyAllowed: ["pop3", "submission"] });
expect(legacyProtocolsPartlyOff(two, "a")).toEqual(["IMAP", "ManageSieve"]);
});
it("is empty with none off, all off, or an older server", () => {
const all = ["imap", "pop3", "manageSieve", "submission"];
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "enabled", legacyAllowed: all }), "a")).toEqual([]);
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "disabled", legacyAllowed: [] }), "a")).toEqual([]);
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "enabled" }), "a")).toEqual([]);
expect(legacyProtocolsPartlyOff(null, "a")).toEqual([]);
});
});
describe("parseTenantLegacy", () => {
it("reads a tenant with only some off, and an older server's one switch", () => {
expect(parseTenantLegacy({ legacyProtocols: "enabled", pop3: "disabled" }).partlyOff).toEqual(["POP3"]);
const all = parseTenantLegacy({ legacyProtocols: "disabled", imap: "disabled", pop3: "disabled" });
expect(all.off).toBe(true);
expect(all.partlyOff).toEqual([]);
expect(parseTenantLegacy({ legacyProtocols: "enabled" }).partlyOff).toEqual([]);
});
});
@@ -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);
});
});
+35 -2
View File
@@ -20,7 +20,40 @@ export const CAP = {
} as const;
/** Stalwart's own capability, which carries its `x:` registry methods. */
export const STALWART_CAP = "urn:stalwart:jmap";
export const STALWART_CAP = "urn:inbuxa:jmap:registry";
/** 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";
}
/** The protocols the server can switch off one at a time, as it names them. */
const SWITCHED = ["imap", "pop3", "manageSieve"] as const;
const PROTOCOL_NAMES: Record<string, string> = { imap: "IMAP", pop3: "POP3", manageSieve: "ManageSieve" };
/**
* Which of IMAP, POP3 and ManageSieve are off for this account, by name, when
* only some are (INBUXA legacy-protocols, one switch per protocol). Empty when
* none are, when all are (see `legacyProtocolsOff`), and from a server that
* doesn't say which (`legacyAllowed`).
*/
export function legacyProtocolsPartlyOff(session: JmapSession | null, accountId: Id | null): string[] {
if (!session || !accountId || legacyProtocolsOff(session, accountId)) return [];
const cap = session.accounts[accountId]?.accountCapabilities?.[INBUXA_CAP] as { legacyAllowed?: unknown } | undefined;
if (!Array.isArray(cap?.legacyAllowed)) return [];
const allowed = cap.legacyAllowed;
return SWITCHED.filter((p) => !allowed.includes(p)).map((p) => PROTOCOL_NAMES[p] ?? p);
}
export class JmapMethodError extends Error {
constructor(
@@ -140,7 +173,7 @@ export class JmapClient {
* Whether the server carries a capability at all, wherever it chose to
* advertise it.
*
* Stalwart hands `urn:stalwart:jmap` out per-account rather than putting it
* Stalwart hands `urn:inbuxa:jmap:registry` out per-account rather than putting it
* in the session-level `capabilities`, so `hasCapability` alone reports every
* real 0.16 server as though it were older. Look in all three places.
*/
+5 -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;
@@ -266,6 +268,8 @@ export interface Email {
"header:Received:asText:all"?: string[] | null;
"header:X-Spam-Status:asText"?: string | null;
"header:X-Spam-Result:asText"?: string | null;
/** inbuxa: the language model's opinion, when AI spam classification is on (lib/llmOpinion). */
"header:X-Spam-LLM:asText"?: string | null;
}
export interface Thread {
@@ -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([]);
});
});
+4 -9
View File
@@ -29,15 +29,10 @@ describe("resolveUiLanguage", () => {
expect(resolveUiLanguage("xx-XX")).toBe("en");
});
it("carries the Beta flag until a person has signed the language off", () => {
// Not a completeness measure. A catalog can be word-for-word finished
// and still read like a machine wrote it, which is what this marks.
// Every shipped language except English is unreviewed, and stays marked
// until a person says otherwise.
for (const l of UI_LANGUAGES) {
if (l.tag === "en") expect(l.beta).toBeUndefined();
else expect(l.beta).toBe(true);
}
it("marks no language Beta", () => {
// inbuxa: every shipped language is offered without the Beta mark
// (John, 2026-09-27); only Dutch has been read by a native speaker.
for (const l of UI_LANGUAGES) expect(l.beta).toBeUndefined();
});
it("honors one that is", () => {
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { LLM_HEADER_PROP, llmOpinion, parseLlmOpinion } from "@/lib/llmOpinion";
/*
* The header as inbuxa-server writes it (crates/features/src/ai/answer.rs):
* `X-Spam-LLM: <TAG>`, optionally followed by the explanation in one pair of
* parentheses, folded at 78 columns.
*/
describe("parseLlmOpinion", () => {
it("reads category, confidence and explanation", () => {
expect(parseLlmOpinion("LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about)")).toEqual({
tag: "LLM_UNSOLICITED_HIGH",
category: "Unsolicited",
confidence: "High",
explanation: "Promotes a product the reader never asked about",
});
});
it("reads a tag with no confidence and no explanation", () => {
expect(parseLlmOpinion("LLM_LEGITIMATE")).toEqual({
tag: "LLM_LEGITIMATE",
category: "Legitimate",
confidence: null,
explanation: null,
});
});
it("keeps an operator's multi-word category whole", () => {
const o = parseLlmOpinion("LLM_COLD_OUTREACH_MEDIUM");
expect(o?.category).toBe("Cold outreach");
expect(o?.confidence).toBe("Medium");
// An unknown last word is part of the category, not a confidence.
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.category).toBe("Cold outreach");
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.confidence).toBeNull();
});
it("unfolds a folded header and keeps inner parentheses", () => {
const o = parseLlmOpinion("LLM_HARMFUL_LOW (Asks for a password\r\n (urgently) via a link)");
expect(o?.explanation).toBe("Asks for a password (urgently) via a link");
});
it("returns null for anything that isn't the server's tag", () => {
for (const raw of [null, undefined, "", " ", "Yes, score=6.7", "LLM_", "llm_unsolicited_high", "X LLM_SPAM"]) {
expect(parseLlmOpinion(raw), String(raw)).toBeNull();
}
});
it("reads the JMAP property a full message carries", () => {
expect(llmOpinion({ [LLM_HEADER_PROP]: "LLM_LEGITIMATE_HIGH" })?.category).toBe("Legitimate");
expect(llmOpinion({})).toBeNull();
});
});
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** inbuxa MA-8: new mail in the accounts not in front. */
const shown: { title: string; opts: Record<string, unknown> }[] = [];
vi.mock("@/lib/notify/notify", () => ({
showNotification: (title: string, opts: Record<string, unknown>) => shown.push({ title, opts }),
}));
const { risen, pollOtherUnread, useOtherUnread } = await import("@/lib/otherAccounts");
const { useSession } = await import("@/store/session");
const { useSettings } = await import("@/store/settings");
let counts: Record<string, number>;
beforeEach(() => {
shown.length = 0;
counts = { b: 3 };
vi.stubGlobal(
"fetch",
vi.fn(async () => {
const body = { accounts: Object.entries(counts).map(([id, unread]) => ({ id, unread })) };
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response;
}),
);
useOtherUnread.getState().set({});
useSession.setState({
signedIn: [
{ id: "a", username: "[email protected]", front: true },
{ id: "b", username: "[email protected]", front: false },
],
});
useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: true } }));
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("other accounts' unread mail", () => {
it("counts only a rise, and never an account seen for the first time", () => {
expect(risen({}, { b: 4 })).toEqual([]);
expect(risen({ b: 4 }, { b: 4 })).toEqual([]);
expect(risen({ b: 4 }, { b: 2 })).toEqual([]);
expect(risen({ b: 4, c: 1 }, { b: 5, c: 1 })).toEqual(["b"]);
});
it("keeps the counts, and names the account when new mail arrives", async () => {
await pollOtherUnread();
expect(useOtherUnread.getState().unread).toEqual({ b: 3 });
expect(shown).toEqual([]);
counts = { b: 5 };
await pollOtherUnread();
expect(shown).toHaveLength(1);
expect(shown[0]!.title).toContain("[email protected]");
expect(shown[0]!.opts.tag).toBe("other-account-b");
});
it("stays quiet when desktop notifications are off", async () => {
useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: false } }));
await pollOtherUnread();
counts = { b: 9 };
await pollOtherUnread();
expect(shown).toEqual([]);
expect(useOtherUnread.getState().unread).toEqual({ b: 9 });
});
it("leaves telling to the worker where background notifications are on", async () => {
const { setDeviceTrusted } = await import("@/lib/storage");
const { setPushEnabledHere } = await import("@/lib/notify/webpush");
setDeviceTrusted(true);
setPushEnabledHere(true);
try {
await pollOtherUnread();
counts = { b: 12 };
await pollOtherUnread();
expect(shown).toEqual([]);
expect(useOtherUnread.getState().unread).toEqual({ b: 12 });
} finally {
setPushEnabledHere(false);
setDeviceTrusted(false);
}
});
});
+55 -8
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
import { isAppFolder } from "../appFolder";
import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
import { loadSettingsOnce, stopSettingsSync } from "../settingsSync";
/**
* Settings used to live only in localStorage, so nothing followed the user
@@ -116,20 +116,67 @@ describe("a change made but not yet written up", () => {
expect(merged.uiLanguage).toBe("en");
});
it("reads the file again for an account after a sign-out", () => {
it("reads the file again for an account after a sign-out", async () => {
stopSettingsSync();
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
let loads = 0;
const load = async () => { loads++; };
await loadSettingsOnce("a1", load);
// The remount that a language change causes must not read it a second time.
expect(settingsAlreadyLoadedFor("a1")).toBe(true);
await loadSettingsOnce("a1", load);
expect(loads).toBe(1);
// Signing out drops the claim, so signing back in reads the file rather
// than trusting whatever the previous session left behind.
stopSettingsSync();
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
await loadSettingsOnce("a1", load);
expect(loads).toBe(2);
stopSettingsSync();
});
it("treats a missing account as already loaded, so nothing is fetched", () => {
expect(settingsAlreadyLoadedFor(null)).toBe(true);
expect(settingsAlreadyLoadedFor(undefined)).toBe(true);
it("finishes a load that a remount interrupts, so changes are saved (Gitea #23)", async () => {
stopSettingsSync();
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
let loads = 0;
let armed = false;
const load = async (isCurrent: () => boolean) => {
loads++;
await gate;
if (!isCurrent()) return;
armed = true;
};
// First mount starts the load; the settings file picks a language and the
// tree remounts before the load has finished.
const first = loadSettingsOnce("a1", load);
const second = loadSettingsOnce("a1", load);
expect(second).toBe(first);
release();
await second;
expect(loads).toBe(1);
// This is what used to be skipped: the remounted tree took the
// already-loaded path and nothing armed the pushes.
expect(armed).toBe(true);
stopSettingsSync();
});
it("stops a load that a sign-out overtakes", async () => {
stopSettingsSync();
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
let applied = false;
const pending = loadSettingsOnce("a1", async (isCurrent) => {
await gate;
if (isCurrent()) applied = true;
});
stopSettingsSync();
release();
await pending;
expect(applied).toBe(false);
});
it("treats a missing account as already loaded, so nothing is fetched", async () => {
let loads = 0;
await loadSettingsOnce(null, async () => { loads++; });
await loadSettingsOnce(undefined, async () => { loads++; });
expect(loads).toBe(0);
});
});
@@ -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, partlyOff: [], recent: [] });
expect(parseTenantLegacy({ legacyProtocols: "enabled" })).toEqual({ off: false, partlyOff: [], 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);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { client, INBUXA_CAP } from "@/jmap/client";
/**
* One tenant's legacy mail protocols switch: INBUXA's
* `inbuxa:TenantProtocolPolicy` (legacy-protocols LP-9 to LP-18).
*
* Turning it off refuses sign-in over IMAP, POP3, ManageSieve and SMTP
* submission on the tenant's domains, so only this webmail and other JMAP
* apps work there. It closes no port -- other tenants share them. Turning it
* back on is refused by the server while the server has legacy protocols off
* for everyone.
*
* Only INBUXA serves it. On any other server the method is unknown, which
* `fetchTenantLegacy` reports as `null` so the sheet shows nothing.
*/
const OBJECT = "inbuxa:TenantProtocolPolicy";
/** The phrase that turns legacy protocols off (LP-17). Turning them back on needs none. */
export const CONFIRM_PHRASE = "turn off legacy mail";
/** Whether the typed confirmation matches: exactly, no trimming, no case folding. */
export function phraseMatches(typed: string): boolean {
return typed === CONFIRM_PHRASE;
}
/** One account's last sign-in over one legacy protocol, as the server reports it (LP-15). */
export interface RecentUse {
accountId: string;
name: string;
protocol: string;
/** Milliseconds since the epoch. */
lastUsedAt: number;
}
export interface TenantLegacy {
/** All of IMAP, POP3 and ManageSieve off: the kill-all's state. */
off: boolean;
/** When only some are off, which, by name; set one at a time in the console. */
partlyOff: string[];
/** Null from a server too old to say who uses legacy apps -- not the same as nobody. */
recent: RecentUse[] | null;
}
function parseRecent(raw: unknown): RecentUse[] | null {
if (!Array.isArray(raw)) return null;
return raw.flatMap((entry) => {
if (!entry || typeof entry !== "object") return [];
const r = entry as Record<string, unknown>;
if (typeof r.name !== "string" || typeof r.protocol !== "string" || typeof r.lastUsedAt !== "number") return [];
return [{ accountId: typeof r.accountId === "string" ? r.accountId : "", name: r.name, protocol: r.protocol, lastUsedAt: r.lastUsedAt }];
});
}
const PROTOCOL_NAMES: [string, string][] = [["imap", "IMAP"], ["pop3", "POP3"], ["manageSieve", "ManageSieve"]];
export function parseTenantLegacy(raw: Record<string, unknown>): TenantLegacy {
const off = raw.legacyProtocols === "disabled";
// An older server sends only legacyProtocols, which stands for all three.
const partlyOff = off ? [] : PROTOCOL_NAMES.filter(([key]) => raw[key] === "disabled").map(([, name]) => name);
return { off, partlyOff, recent: parseRecent(raw.recentLegacyUse) };
}
/** The tenant's switch, or null where the server has none (not INBUXA, or too old). */
export async function fetchTenantLegacy(tenantId: string): Promise<TenantLegacy | null> {
try {
const res = await client.call<{ list?: Record<string, unknown>[] }>(`${OBJECT}/get`, { ids: [tenantId] }, [INBUXA_CAP]);
return res.list?.[0] ? parseTenantLegacy(res.list[0]) : null;
} catch {
return null;
}
}
/** Turns the tenant's switch. A refusal (LP-9) comes back as the server's words. */
export async function setTenantLegacy(tenantId: string, off: boolean): Promise<void> {
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> | null }>(
`${OBJECT}/set`,
{ update: { [tenantId]: { legacyProtocols: off ? "disabled" : "enabled" } } },
[INBUXA_CAP],
);
const failed = res.notUpdated?.[tenantId];
if (failed) throw new Error(failed.description ?? failed.type);
}
const PROTOCOL_ORDER = ["imap", "pop3", "manageSieve", "submission"];
const PROTOCOL_LABELS: Record<string, string> = { imap: "IMAP", pop3: "POP3", manageSieve: "ManageSieve", submission: "SMTP" };
/** One account on the impact panel: every protocol it used, and when it last used any. */
export interface ImpactEntry {
name: string;
protocols: string[];
lastUsedAt: number;
}
/** The impact panel's lines (LP-15): one per account, most recent first. */
export function impactEntries(recent: RecentUse[]): ImpactEntry[] {
const byAccount = new Map<string, { name: string; protocols: Set<string>; lastUsedAt: number }>();
for (const use of recent) {
const key = use.accountId || use.name;
const entry = byAccount.get(key) ?? { name: use.name, protocols: new Set<string>(), lastUsedAt: 0 };
entry.protocols.add(use.protocol);
entry.lastUsedAt = Math.max(entry.lastUsedAt, use.lastUsedAt);
byAccount.set(key, entry);
}
return [...byAccount.values()]
.map((e) => ({
name: e.name,
protocols: [...e.protocols].sort((a, b) => PROTOCOL_ORDER.indexOf(a) - PROTOCOL_ORDER.indexOf(b)).map((p) => PROTOCOL_LABELS[p] ?? p),
lastUsedAt: e.lastUsedAt,
}))
.sort((a, b) => b.lastUsedAt - a.lastUsedAt || a.name.localeCompare(b.name));
}
+49 -1
View File
@@ -1,3 +1,6 @@
import { useSession } from "@/store/session";
import { withBase } from "@/lib/basePath";
/**
* What this instance calls itself, when nothing has said otherwise yet.
*
@@ -10,4 +13,49 @@
* One constant rather than the string written out at each of them, because
* three copies of a default is how two of them end up stale.
*/
export const DEFAULT_APP_NAME = "ihasmail";
// ihasmail-inbuxa: inbuxa's webmail goes by inbuxa, so it can't be taken for
// public ihasmail. APP_NAME still names a deployment whatever it likes.
export const DEFAULT_APP_NAME = "inbuxa";
/**
* What this instance calls itself, right now.
*
* Text that names the app reads it from here rather than writing "ihasmail"
* into the sentence, so an instance renamed with `APP_NAME` is called by its
* name everywhere, not only on the sign-in page and in the title bar. The
* name goes into the sentence as the `{app}` placeholder, which also lets a
* translator put it where their language wants it.
*
* Two shapes for the same fact: the hook for components, and the plain
* function for the few places that build strings outside React (the service
* worker's facts, for one). Both fall back to the default until the session
* arrives.
*/
export function useAppName(): string {
return useSession((s) => s.session?.ihasmail?.appName)?.trim() || DEFAULT_APP_NAME;
}
export function currentAppName(): string {
return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME;
}
/**
* The brand images' version, carried as `?v=` on every URL that names one.
*
* The images live in `public/img` under fixed names and are served with a
* browser cache of hours, so replacing one (the mark changed on 2026-09-27)
* left returning visitors on the old picture until their copy expired -- and
* the favicon and an installed app's icon hold on longer still. A new value
* here is a new URL everywhere at once.
*
* Date-stamped with a letter for a second change the same day, never a
* counter, for the reason the sites give for ASSET_V: a value that could have
* been requested before may already be cached, with old bytes behind it.
* `index.html`, `public/manifest.webmanifest` and `public/sw.js` can't import
* this, so they carry the same value written out; keep the four in step.
*/
export const BRAND_V = "2026-09-27a";
/** A brand image's URL under the mount, versioned: `brandImage("/img/logo.png")`. */
export function brandImage(path: string): string {
return withBase(`${path}?v=${BRAND_V}`);
}
@@ -10,6 +10,8 @@ import {
snap,
movePatch,
moveByDaysPatch,
moveAcrossPatch,
columnsMoved,
dayDelta,
resizePatch,
SNAP_MINUTES,
@@ -180,10 +182,22 @@ describe("the patch a drag sends, computed in the event's own frame", () => {
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
});
it("moves by days and minutes together, as a week-grid drag does", () => {
expect(moveAcrossPatch("2026-09-04T14:00:00", 2, 90)).toEqual({ start: "2026-09-06T15:30:00" });
expect(moveAcrossPatch("2026-09-04T14:00:00", -1, 0)).toEqual({ start: "2026-09-03T14:00:00" });
expect(moveAcrossPatch("2026-09-04T14:00:00", 0, -30)).toEqual({ start: "2026-09-04T13:30:00" });
});
it("adds the days as days, so a clock change does not move the hour", () => {
// US clocks go back on 1 November 2026; 14:00 stays 14:00 across it.
expect(moveAcrossPatch("2026-10-31T14:00:00", 2, 0)).toEqual({ start: "2026-11-02T14:00:00" });
});
it("says nothing at all about a start it cannot read", () => {
expect(movePatch("not a date", 30)).toEqual({});
expect(moveByDaysPatch("", 3)).toEqual({});
expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({});
expect(moveAcrossPatch("not a date", 1, 30)).toEqual({});
});
});
@@ -228,3 +242,23 @@ describe("pixelsToMinutes", () => {
expect(SNAP_MINUTES).toBe(15);
});
});
describe("columnsMoved", () => {
it("counts whole columns, to the nearest", () => {
expect(columnsMoved(100, 100, 2, 7)).toBe(1);
expect(columnsMoved(140, 100, 2, 7)).toBe(1);
expect(columnsMoved(160, 100, 2, 7)).toBe(2);
expect(columnsMoved(-40, 100, 2, 7)).toBe(0);
expect(columnsMoved(-160, 100, 3, 7)).toBe(-2);
});
it("stops at the edges of the week instead of wrapping", () => {
expect(columnsMoved(-900, 100, 2, 7)).toBe(-2);
expect(columnsMoved(900, 100, 2, 7)).toBe(4);
});
it("never moves sideways in a one-day grid or before it is measured", () => {
expect(columnsMoved(500, 100, 0, 1)).toBe(0);
expect(columnsMoved(500, 0, 0, 7)).toBe(0);
});
});
+29
View File
@@ -142,6 +142,35 @@ export function moveByDaysPatch(storedStart: string, days: number): DragPatch {
return { start: formatStored(moved) };
}
/**
* Moved by whole days and by minutes at once -- the week grid, where a drag
* goes sideways to another day and up or down to another hour in the same
* gesture.
*
* The days go first and as days, for the reason moveByDaysPatch gives: a day
* added to a wall clock keeps its time of day across a clock change, where
* 1440 minutes would not.
*/
export function moveAcrossPatch(storedStart: string, days: number, deltaMinutes: number): DragPatch {
const byDays = days ? moveByDaysPatch(storedStart, days).start : storedStart;
if (!byDays) return {};
return snap(deltaMinutes) ? movePatch(byDays, deltaMinutes) : { start: byDays };
}
/**
* How many columns sideways the pointer has gone, kept inside the grid.
*
* Counted from the column the drag began in, so an event that crosses
* midnight moves by the same amount whichever of its two halves was picked
* up. Past the first or last column it stops at the edge rather than
* wrapping: the week on screen is the only week a drag can reach.
*/
export function columnsMoved(deltaPixels: number, columnWidth: number, fromIndex: number, columnCount: number): number {
if (!columnWidth || columnCount < 2) return 0;
const moved = Math.round(deltaPixels / columnWidth) || 0; // never -0
return Math.max(-fromIndex, Math.min(columnCount - 1 - fromIndex, moved));
}
/** Whole days between two local dates, ignoring the time of day on each. */
export function dayDelta(from: Date, to: Date): number {
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
+74
View File
@@ -0,0 +1,74 @@
/**
* Locked accounts handed to the reader (inbuxa audit-hold-lock spec, AL-7).
*
* The server marks one in the account's `urn:inbuxa:jmap` capability, as
* `delegation: {locked, access, sendAs, until}`. Only that mark makes an
* account switchable: a server advertises every capability on any shared
* account, so "it has mail" proves nothing about what was handed over.
*/
import type { Id, JmapSession } from "@/jmap/types";
export type DelegationAccess = "read" | "organize" | "full";
export interface Delegation {
locked: boolean;
/**
* A locked account, or a shared mailbox such as support@ (MA-S). Both are
* reached as a delegate at an access level; only how they are shown
* differs. Absent from older servers, where it is always a lock.
*/
kind: "lock" | "sharedMailbox";
access: DelegationAccess;
sendAs: boolean;
/** UTC date the delegation ends, if it does. */
until: string | null;
}
export interface DelegatedAccount {
id: Id;
name: string;
delegation: Delegation;
}
const INBUXA = "urn:inbuxa:jmap";
type SessionLike = Pick<JmapSession, "accounts">;
export function delegationOf(session: SessionLike | null, accountId: Id | null): Delegation | null {
if (!session || !accountId) return null;
const account = session.accounts[accountId];
if (!account || account.isPersonal) return null;
const raw = (account.accountCapabilities?.[INBUXA] as { delegation?: Partial<Delegation> } | undefined)?.delegation;
if (!raw || raw.locked !== true) return null;
const access: DelegationAccess = raw.access === "organize" || raw.access === "full" ? raw.access : "read";
return {
locked: true,
kind: raw.kind === "sharedMailbox" ? "sharedMailbox" : "lock",
access,
sendAs: raw.sendAs === true && access !== "read",
until: typeof raw.until === "string" ? raw.until : null,
};
}
/** Every locked account handed to the reader, by name. Shared mailboxes are listed by lib/sharedMail. */
export function delegatedAccounts(session: SessionLike | null): DelegatedAccount[] {
if (!session) return [];
return Object.entries(session.accounts)
.map(([id, account]) => {
const delegation = delegationOf(session, id);
return delegation?.kind === "lock" ? { id, name: account.name, delegation } : null;
})
.filter((a): a is DelegatedAccount => a !== null)
.sort((a, b) => a.name.localeCompare(b.name));
}
/** Whether the reader may change anything in the account (AL-6). */
export function mayWrite(delegation: Delegation | null): boolean {
return !delegation || delegation.access !== "read";
}
/** Whether the reader may delete in the account (AL-6). */
export function mayDestroy(delegation: Delegation | null): boolean {
return !delegation || delegation.access === "full";
}
+1 -1
View File
@@ -120,7 +120,7 @@ export function plural(n: number, forms: PluralForms, vars?: Vars): string {
*
* So the sentence stays whole and the elements are placeholders in it:
*
* tNode("Open {scheme} links in ihasmail.", { scheme: <code>mailto:</code> })
* tNode("Open {scheme} links in {app}.", { scheme: <code>mailto:</code> }, { app: "ihasmail" })
*
* A translator sees one sentence with a named hole and can put the hole
* wherever their language wants it.
+13 -10
View File
@@ -35,21 +35,24 @@ export interface UiLanguage {
beta?: boolean;
}
// inbuxa: no language is marked Beta (John, 2026-09-27). The flag and its
// settings note stay, so public ihasmail's changes to them still apply.
export const UI_LANGUAGES: readonly UiLanguage[] = [
{ tag: "en", name: "English" },
{ tag: "de", name: "Deutsch", beta: true },
{ tag: "es", name: "Español", beta: true },
{ tag: "fr", name: "Français", beta: true },
{ tag: "nl", name: "Nederlands", beta: true },
{ tag: "pt-BR", name: "Português (Brasil)", beta: true },
{ tag: "ja", name: "日本語", beta: true },
{ tag: "ru", name: "Русский", beta: true },
{ tag: "uk", name: "Українська", beta: true },
{ tag: "zh-Hans", name: "简体中文", beta: true },
{ tag: "de", name: "Deutsch" },
{ tag: "es", name: "Español" },
{ tag: "fr", name: "Français" },
{ tag: "nl", name: "Nederlands" },
{ tag: "pt-BR", name: "Português (Brasil)" },
{ tag: "ja", name: "日本語" },
{ tag: "ru", name: "Русский" },
{ tag: "uk", name: "Українська" },
{ tag: "zh-Hans", name: "简体中文" },
{ tag: "tr", name: "Türkçe" },
];
/** Where to report a bad translation. Beta languages depend on it. */
export const TRANSLATION_ISSUE_URL = "https://github.com/Coffey-Labs/ihasmail/issues/new?title=Translation%3A%20";
export const TRANSLATION_ISSUE_URL = "https://git.coffeylabs.org/coffey-labs/ihasmail/issues/new?title=Translation%3A%20";
export const DEFAULT_UI_LANGUAGE = "en";
+80
View File
@@ -0,0 +1,80 @@
/**
* inbuxa: the language model's opinion, read back off the message.
*
* When the server's AI spam classification is on (inbuxa-server,
* docs/spec/features/ai-spam-classification.md), it writes the model's answer
* into an `X-Spam-LLM` header at delivery:
*
* X-Spam-LLM: LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about)
*
* a tag, then optionally the model's explanation in parentheses. The tag is
* `LLM_` + category, or `LLM_` + category + `_` + confidence, uppercased with
* anything outside A-Z and 0-9 turned into `_`. The explanation is already
* sanitized by the server and may arrive as encoded words, which the JMAP
* `asText` form decodes.
*
* Like `spamScore`, nothing here judges anything: it only reads what the
* server wrote. It is one signal the spam filter weighed among many, and the
* UI says so.
*/
/** The JMAP property that carries the header, decoded and unfolded. */
export const LLM_HEADER_PROP = "header:X-Spam-LLM:asText" as const;
/**
* Confidence words the fork's default prompt uses. A tag ending in one of
* these is read as category + confidence; anything else is all category,
* since an operator's own categories may contain underscores.
*/
const CONFIDENCES = new Set(["LOW", "MEDIUM", "HIGH"]);
export interface LlmOpinion {
/** The tag as the server wrote it, e.g. `LLM_UNSOLICITED_HIGH`. */
tag: string;
/** Readable category, e.g. `Unsolicited`. */
category: string;
/** Readable confidence, e.g. `High`, where the tag carried one. */
confidence: string | null;
/** The model's own explanation, as plain text, where there is one. */
explanation: string | null;
}
/** `UNSOLICITED_BULK` -> `Unsolicited bulk`. */
function readable(words: string[]): string {
const s = words.join(" ").toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Headers arrive folded, so tabs and newlines are whitespace like any other. */
function flatten(v: string | null | undefined): string {
return (v ?? "").replace(/\s+/g, " ").trim();
}
export function parseLlmOpinion(raw: string | null | undefined): LlmOpinion | null {
const s = flatten(raw);
const m = /^(LLM_[A-Z0-9_]+)(?:\s+(.*))?$/.exec(s);
if (!m) return null;
const tag = m[1]!;
const parts = tag.slice("LLM_".length).split("_").filter(Boolean);
if (parts.length === 0) return null;
let confidence: string | null = null;
if (parts.length > 1 && CONFIDENCES.has(parts[parts.length - 1]!)) {
confidence = readable([parts.pop()!]);
}
let explanation: string | null = null;
const rest = (m[2] ?? "").trim();
if (rest) {
// The server wraps the explanation in one pair of parentheses.
const inner = rest.startsWith("(") && rest.endsWith(")") ? rest.slice(1, -1).trim() : rest;
explanation = inner || null;
}
return { tag, category: readable(parts), confidence, explanation };
}
/** The opinion on a message, if the server recorded one. */
export function llmOpinion(email: { [LLM_HEADER_PROP]?: string | null }): LlmOpinion | null {
return parseLlmOpinion(email[LLM_HEADER_PROP]);
}
+65
View File
@@ -0,0 +1,65 @@
import { unproxiedImageUrl } from "@/lib/text/html";
import type { ImagePolicy } from "@/store/settings";
/**
* Whether a message's remote images may be fetched.
*
* The reader's decision, in one place, because the composer has to make the
* same one. Quoting a message into a reply renders it again — and a quote that
* fetched what the reader had declined would report the message read, and the
* address live, to whoever was counting. The tracking pixel does not care
* which window it loaded in.
*/
export function remoteImagesAllowed(opts: {
from: string | null | undefined;
policy: ImagePolicy;
trusted: string[];
inContacts: boolean;
/** The reader pressed "Show images" on this message. */
shown: boolean;
}): boolean {
if (opts.shown || opts.policy === "always") return true;
if (opts.trusted.includes((opts.from ?? "").toLowerCase())) return true;
return opts.policy === "contacts" && opts.inContacts;
}
/**
* Point proxied images back at their own addresses, on the way out.
*
* Reading a message fetches its remote images through this server, so the
* sender learns nothing about the reader. Those URLs belong to this
* deployment, so a quote that kept them would reach the recipient as images
* only this server can serve -- broken for them, and a beacon back here for
* anyone who could load them (#412).
*/
export function unproxyImages(html: string): string {
if (!html.includes("/api/image?url=")) return html;
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of Array.from(doc.querySelectorAll("img[src]"))) {
const real = unproxiedImageUrl(img.getAttribute("src") ?? "");
if (real) img.setAttribute("src", real);
}
return doc.body.innerHTML;
}
/**
* Put back the addresses of images that were blocked when the message was
* quoted, on the way out.
*
* Blocking keeps the original URL on the element (`data-ihm-remote`), so
* nothing was lost by not fetching it. The copy that leaves here should be the
* quote as its sender wrote it: the recipient's client decides for itself
* whether to load those images, the same as it would have with any other
* client's reply.
*/
export function restoreBlockedImages(html: string): string {
if (!html.includes("data-ihm-blocked")) return html;
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of Array.from(doc.querySelectorAll("img[data-ihm-blocked]"))) {
const url = img.getAttribute("data-ihm-remote");
if (url) img.setAttribute("src", url);
img.removeAttribute("data-ihm-blocked");
img.removeAttribute("data-ihm-remote");
}
return doc.body.innerHTML;
}
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf, treeOrder } from "../folderOrder";
import type { Id, Mailbox } from "@/jmap/types";
const RIGHTS = { mayRename: true, mayCreateChild: true } as Mailbox["myRights"];
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null, sortOrder = 0, over: Partial<Mailbox> = {}): Mailbox =>
({ id, name, parentId, role, sortOrder, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: RIGHTS, ...over });
const tree = (...list: Mailbox[]): Record<Id, Mailbox> => Object.fromEntries(list.map((m) => [m.id, m]));
/** As Stalwart hands it over before anybody orders anything: every sortOrder 0. */
const fresh = tree(
mb("zeta", "Zeta", null),
mb("trash", "Deleted Items", null, "trash"),
mb("sent", "Sent Items", null, "sent"),
mb("inbox", "Inbox", null, "inbox"),
mb("alpha", "Alpha", null),
mb("junk", "Junk Mail", null, "junk"),
mb("drafts", "Drafts", null, "drafts"),
mb("work", "Work", null),
mb("clients", "Clients", "work"),
);
const names = (all: Record<Id, Mailbox>, parentId: Id | null = null) => siblingsOf(all, parentId).map((m) => m.id);
/** Apply what `placeFolder` asks for, as the server would. */
function apply(all: Record<Id, Mailbox>, updates: Record<Id, Partial<Mailbox>> | null): Record<Id, Mailbox> {
const next = { ...all };
for (const [id, patch] of Object.entries(updates ?? {})) next[id] = { ...next[id]!, ...patch };
return next;
}
describe("compareFolders", () => {
it("lists Inbox, then the special folders in mail-client order, then the rest A–Z, when nothing is ordered yet", () => {
// #402: Sent landed fourth from the bottom among the reporter's 88 folders.
expect(names(fresh)).toEqual(["inbox", "drafts", "sent", "junk", "trash", "alpha", "work", "zeta"]);
});
it("puts a saved order ahead of the special-folder default", () => {
const ordered = apply(fresh, { zeta: { sortOrder: 10 }, sent: { sortOrder: 20 }, alpha: { sortOrder: 30 }, drafts: { sortOrder: 40 }, junk: { sortOrder: 50 }, trash: { sortOrder: 60 }, work: { sortOrder: 70 } });
expect(names(ordered)).toEqual(["inbox", "zeta", "sent", "alpha", "drafts", "junk", "trash", "work"]);
});
it("keeps Inbox first whatever its sortOrder says", () => {
const a = mb("inbox", "Inbox", null, "inbox", 99);
const b = mb("alpha", "Alpha", null, null, 1);
expect(compareFolders(a, b)).toBeLessThan(0);
});
it("sorts names numerically, not by character", () => {
const all = tree(mb("f10", "Folder 10", null), mb("f9", "Folder 9", null));
expect(names(all)).toEqual(["f9", "f10"]);
});
});
describe("placeFolder", () => {
it("numbers the whole level 10 apart, with the folder where it was dropped", () => {
const next = apply(fresh, placeFolder(fresh, "zeta", "drafts", "before"));
expect(names(next)).toEqual(["inbox", "zeta", "drafts", "sent", "junk", "trash", "alpha", "work"]);
expect(siblingsOf(next, null).map((m) => m.sortOrder)).toEqual([10, 20, 30, 40, 50, 60, 70, 80]);
});
it("writes only the folders whose number changes", () => {
const once = apply(fresh, placeFolder(fresh, "zeta", "drafts", "before"));
// Swapping the last two leaves everything above them where it was.
expect(Object.keys(placeFolder(once, "work", "alpha", "before")!).sort()).toEqual(["alpha", "work"]);
});
it("asks for nothing when the folder is dropped where it already is", () => {
expect(placeFolder(fresh, "sent", "drafts", "after")).toBeNull();
expect(placeFolder(fresh, "sent", "junk", "before")).toBeNull();
});
it("moves a folder to another level, and gives it a place there", () => {
const updates = placeFolder(fresh, "alpha", "clients", "before")!;
expect(updates.alpha).toEqual({ sortOrder: 10, parentId: "work" });
expect(names(apply(fresh, updates), "work")).toEqual(["alpha", "clients"]);
});
});
describe("canPlaceFolder", () => {
it("lets a special folder be reordered among its siblings", () => {
expect(canPlaceFolder(fresh, "sent", "alpha", "after")).toBe(true);
});
it("does not let a special folder move to another level", () => {
expect(canPlaceFolder(fresh, "sent", "clients", "before")).toBe(false);
});
it("puts nothing above Inbox", () => {
expect(canPlaceFolder(fresh, "sent", "inbox", "before")).toBe(false);
expect(canPlaceFolder(fresh, "sent", "inbox", "after")).toBe(true);
});
it("does not put a folder inside its own subtree", () => {
expect(canPlaceFolder(fresh, "work", "clients", "before")).toBe(false);
});
it("needs the right to rename, which RFC 8621 folds moving into", () => {
const locked = apply(fresh, { alpha: { myRights: { ...RIGHTS, mayRename: false } } });
expect(canPlaceFolder(locked, "alpha", "zeta", "after")).toBe(false);
});
});
describe("neighbour", () => {
it("steps past the folder above or below", () => {
expect(neighbour(fresh, "alpha", "up")).toEqual({ targetId: "trash", placement: "before" });
expect(neighbour(fresh, "alpha", "down")).toEqual({ targetId: "work", placement: "after" });
});
it("has nowhere to go past either end, or above Inbox", () => {
expect(neighbour(fresh, "zeta", "down")).toBeNull();
expect(neighbour(fresh, "drafts", "up")).toBeNull();
});
it("skips folders that aren't on screen, so every step visibly moves", () => {
const hidden = apply(fresh, { trash: { isSubscribed: false } });
expect(neighbour(hidden, "alpha", "up", (m) => m.isSubscribed)).toEqual({ targetId: "junk", placement: "before" });
});
});
describe("treeOrder", () => {
const ids = (all: Record<Id, Mailbox>) => treeOrder(all).map((m) => m.id);
it("lists the tree the way the sidebar does, each folder followed by its subfolders", () => {
expect(ids(fresh)).toEqual(["inbox", "drafts", "sent", "junk", "trash", "alpha", "work", "clients", "zeta"]);
});
it("follows a saved order rather than A–Z", () => {
// #1 on GitLab: the move-to picker kept the old order after the sidebar changed.
const ordered = apply(fresh, { zeta: { sortOrder: 10 }, sent: { sortOrder: 20 }, alpha: { sortOrder: 30 }, drafts: { sortOrder: 40 }, junk: { sortOrder: 50 }, trash: { sortOrder: 60 }, work: { sortOrder: 70 } });
expect(ids(ordered)).toEqual(["inbox", "zeta", "sent", "alpha", "drafts", "junk", "trash", "work", "clients"]);
});
it("still lists a folder the walk from the top can't reach", () => {
const looped = apply(fresh, { work: { parentId: "clients" } });
expect(ids(looped)).toHaveLength(Object.keys(looped).length);
expect(ids(looped)).toEqual(expect.arrayContaining(["work", "clients"]));
});
});
+132
View File
@@ -0,0 +1,132 @@
import type { Id, Mailbox } from "@/jmap/types";
import { ROLE_ORDER } from "@/store/mail/mailboxes";
import { canDropFolder, descendantIds } from "./folderMove";
/**
* The order folders are listed in, at every level of the tree (#402).
*
* Inbox always comes first. After that the folder's own `sortOrder` decides,
* which is where a folder dragged into place keeps its position, and where
* any other JMAP client that orders folders keeps its choice too. Stalwart
* gives every folder 0 until somebody orders it, so for everyone who never
* has, the tie-breaks decide: special folders first, in the usual mail-client
* order (Drafts, Sent, Archive, Junk, Trash), then the rest A–Z.
*/
export function compareFolders(a: Mailbox, b: Mailbox): number {
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
const ra = roleRank(a);
const rb = roleRank(b);
if (ra !== rb) return ra - rb;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
}
function roleRank(m: Mailbox): number {
return m.role && m.role in ROLE_ORDER ? ROLE_ORDER[m.role]! : Number.MAX_SAFE_INTEGER;
}
/**
* Every folder, parents before their children and siblings in
* `compareFolders` order: the sidebar's order with every folder expanded.
* Lists that show all folders at once, like the move-to picker, use this so a
* folder sits where the user dragged it rather than where A–Z would put it.
*
* A folder the walk from the top never reaches (a parent loop the server
* should not allow) is appended rather than dropped, so it can still be
* picked.
*/
export function treeOrder(mailboxes: Record<Id, Mailbox>): Mailbox[] {
const byParent = new Map<Id | null, Mailbox[]>();
for (const m of Object.values(mailboxes)) {
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
byParent.set(p, [...(byParent.get(p) ?? []), m]);
}
for (const list of byParent.values()) list.sort(compareFolders);
const out: Mailbox[] = [];
const seen = new Set<Id>();
const walk = (parent: Id | null) => {
for (const m of byParent.get(parent) ?? []) {
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push(m);
walk(m.id);
}
};
walk(null);
return out.concat(Object.values(mailboxes).filter((m) => !seen.has(m.id)).sort(compareFolders));
}
/** Every folder under `parentId` (null: the top level), in list order. */
export function siblingsOf(mailboxes: Record<Id, Mailbox>, parentId: Id | null): Mailbox[] {
return Object.values(mailboxes)
.filter((m) => (m.parentId && mailboxes[m.parentId] ? m.parentId : null) === parentId)
.sort(compareFolders);
}
export type Placement = "before" | "after";
/**
* Whether `draggedId` may be put just above or below `targetId`.
*
* Special folders can be reordered but not reparented, so they may only land
* among their own siblings. Nothing goes above Inbox, which stays first.
*/
export function canPlaceFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id, placement: Placement): boolean {
const dragged = mailboxes[draggedId];
const target = mailboxes[targetId];
if (!dragged || !target || draggedId === targetId) return false;
if (!dragged.myRights.mayRename) return false;
if (target.role === "inbox" && placement === "before") return false;
if (descendantIds(mailboxes, draggedId).has(targetId)) return false;
const from = parentOf(mailboxes, dragged);
const to = parentOf(mailboxes, target);
return from === to || canDropFolder(mailboxes, draggedId, to);
}
/**
* The updates that put `draggedId` just above or below `targetId`, or null when
* it is already there.
*
* The new level is numbered afresh, 10 apart, so that another client can put
* a folder between two of them without renumbering. Only folders whose number
* actually changes are written.
*/
export function placeFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id, placement: Placement): Record<Id, Partial<Mailbox>> | null {
const dragged = mailboxes[draggedId]!;
const parentId = parentOf(mailboxes, mailboxes[targetId]!);
const reparent = parentOf(mailboxes, dragged) !== parentId;
const current = siblingsOf(mailboxes, parentId);
const order = current.filter((m) => m.id !== draggedId);
const at = order.findIndex((m) => m.id === targetId) + (placement === "after" ? 1 : 0);
order.splice(at, 0, dragged);
// Dropped where it already was. Renumbering would change nothing anyone sees.
if (!reparent && order.every((m, i) => m.id === current[i]!.id)) return null;
const updates: Record<Id, Partial<Mailbox>> = {};
order.forEach((m, i) => {
const sortOrder = (i + 1) * 10;
if (m.sortOrder !== sortOrder) updates[m.id] = { sortOrder };
});
if (reparent) updates[draggedId] = { ...updates[draggedId], parentId };
return updates;
}
/**
* The neighbour to place a folder against for "Move up" / "Move down", if it
* has one. Only folders on screen count (`shown`), so each step visibly moves
* the folder rather than passing a hidden one.
*/
export function neighbour(mailboxes: Record<Id, Mailbox>, id: Id, direction: "up" | "down", shown: (m: Mailbox) => boolean = () => true): { targetId: Id; placement: Placement } | null {
const m = mailboxes[id];
if (!m) return null;
const level = siblingsOf(mailboxes, parentOf(mailboxes, m)).filter((x) => x.id === id || shown(x));
const i = level.findIndex((x) => x.id === id);
const other = level[direction === "up" ? i - 1 : i + 1];
if (!other) return null;
const placement = direction === "up" ? "before" : "after";
return canPlaceFolder(mailboxes, id, other.id, placement) ? { targetId: other.id, placement } : null;
}
function parentOf(mailboxes: Record<Id, Mailbox>, m: Mailbox): Id | null {
return m.parentId && mailboxes[m.parentId] ? m.parentId : null;
}
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { otherAccountCall } from "@/lib/notify/otherAccount";
import { listSubscriptions } from "@/lib/notify/webpush";
/** inbuxa MA-8: push calls made as an account that isn't in front. */
afterEach(() => vi.unstubAllGlobals());
function stub(response: unknown) {
const seen: { url: string; body: any }[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
return { ok: true, status: 200, json: async () => response, text: async () => JSON.stringify(response) } as Response;
}),
);
return seen;
}
describe("otherAccountCall", () => {
it("goes through that account's route and answers the method's result", async () => {
const seen = stub({ methodResponses: [["PushSubscription/get", { list: [{ id: "p1", deviceClientId: "d" }] }, "0"]] });
const subs = await listSubscriptions(otherAccountCall("sess-2"));
expect(subs.map((s) => s.id)).toEqual(["p1"]);
expect(seen[0]!.url).toContain("/api/auth/accounts/sess-2/jmap");
expect(seen[0]!.body.methodCalls[0][0]).toBe("PushSubscription/get");
expect(seen[0]!.body.using).toContain("urn:ietf:params:jmap:core");
});
it("turns a JMAP error into a thrown one", async () => {
stub({ methodResponses: [["error", { type: "forbidden" }, "0"]] });
await expect(listSubscriptions(otherAccountCall("sess-2"))).rejects.toThrow("forbidden");
});
});
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearSignedInData, setDeviceTrusted } from "@/lib/storage";
import { deviceClientId, registeredEndpoint, rememberEndpoint, unsubscribeAccount, type JmapCall } from "@/lib/notify/webpush";
/** inbuxa MA-8 part 2: every signed-in account on this device has its own push subscription. */
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
describe("push for every signed-in account", () => {
it("remembers each account's endpoint, and keeps them through a switch", () => {
rememberEndpoint("https://push.example/old");
// An account with nothing of its own yet reads the endpoint from before
expect(registeredEndpoint("acc-a")).toBe("https://push.example/old");
rememberEndpoint("https://push.example/a", "acc-a");
rememberEndpoint("https://push.example/b", "acc-b");
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
expect(registeredEndpoint("acc-b")).toBe("https://push.example/b");
localStorage.setItem("ihasmail:cached-mail", "x");
clearSignedInData();
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
expect(localStorage.getItem("ihasmail:cached-mail")).toBeNull();
rememberEndpoint(null, "acc-a");
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
});
it("removes only this device's subscription from one account", async () => {
// A device id is kept only where the device is trusted
setDeviceTrusted(true);
const mine = deviceClientId();
const destroyed: string[] = [];
const call: JmapCall = async <T,>(method: string, args: Record<string, unknown>) => {
if (method === "PushSubscription/get") {
return { list: [{ id: "p1", deviceClientId: mine }, { id: "p2", deviceClientId: "ihasmail-other-phone" }] } as T;
}
destroyed.push(...((args.destroy as string[]) ?? []));
return {} as T;
};
rememberEndpoint("https://push.example/a", "acc-a");
await unsubscribeAccount(call, "acc-a");
expect(destroyed).toEqual(["p1"]);
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
setDeviceTrusted(false);
});
});
+5 -5
View File
@@ -1,6 +1,6 @@
import { withBase } from "../basePath";
import { brandImage } from "@/lib/brand";
let baseTitle = "ihasmail";
let baseTitle = "inbuxa";
let faviconCanvas: HTMLCanvasElement | null = null;
let baseFavicon: HTMLImageElement | null = null;
@@ -40,13 +40,13 @@ export function setUnreadBadge(count: number): void {
if (!link) return;
if (!baseFavicon) {
baseFavicon = new Image();
baseFavicon.src = withBase("/img/favicon-64.png");
baseFavicon.src = brandImage("/img/favicon-64.png");
baseFavicon.onload = () => setUnreadBadge(count);
return;
}
if (!baseFavicon.complete) return;
if (count <= 0) {
link.href = withBase("/img/favicon-64.png");
link.href = brandImage("/img/favicon-64.png");
return;
}
faviconCanvas ??= document.createElement("canvas");
@@ -95,7 +95,7 @@ export function showNotification(title: string, opts: NotificationOptions & { on
if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return;
const { onClick, ...options } = opts;
const full = { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...options };
const full = { icon: brandImage("/img/icon-192.png"), badge: brandImage("/img/favicon-64.png"), ...options };
const viaWorker = navigator.serviceWorker?.controller ? navigator.serviceWorker.ready : null;
if (viaWorker) {
void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined);
+22
View File
@@ -0,0 +1,22 @@
/**
* inbuxa MA-8: JMAP calls made as a signed-in account that isn't in front,
* through the webmail server's narrow route for it
* (POST /api/auth/accounts/<sessionId>/jmap). The server allows only what
* notifications need: push subscriptions, mailboxes, and marking or filing
* mail.
*/
import { apiFetch, CAP } from "@/jmap/client";
import type { JmapCall } from "@/lib/notify/webpush";
export function otherAccountCall(sessionId: string): JmapCall {
return async <T,>(method: string, args: Record<string, unknown>, using: string[]): Promise<T> => {
const res = await apiFetch<{ methodResponses?: [string, unknown, string][] }>(
`/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap`,
{ method: "POST", body: JSON.stringify({ using: [...new Set([CAP.core, ...using])], methodCalls: [[method, args, "0"]] }) },
);
const [name, out] = res.methodResponses?.[0] ?? [];
if (!name) throw new Error("The mail server sent no response.");
if (name === "error") throw new Error(String((out as { type?: string } | undefined)?.type ?? "error"));
return out as T;
};
}
+49 -15
View File
@@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types";
import { isDeviceTrusted } from "@/lib/storage";
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
/**
* Who a push call is made as (inbuxa MA-8). A JMAP push subscription belongs
* to whoever signs the request, so registering one for an account that isn't
* in front goes through that account's own session (lib/notify/otherAccount).
* Everything here defaults to the account in front.
*/
export type JmapCall = <T>(method: string, args: Record<string, unknown>, using: string[]) => Promise<T>;
export const frontCall: JmapCall = (method, args, using) => client.call(method, args, using);
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
/**
@@ -285,13 +294,13 @@ export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now
return at - now <= RENEW_WITHIN_MS;
}
export async function listSubscriptions(): Promise<JmapPushSubscription[]> {
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
export async function listSubscriptions(call: JmapCall = frontCall): Promise<JmapPushSubscription[]> {
const res = await call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
return res.list;
}
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
export async function createSubscription(body: Record<string, unknown>, call: JmapCall = frontCall): Promise<Id | null> {
const res = await call<SetResponse<JmapPushSubscription>>(
"PushSubscription/set",
{ create: { s: body } },
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
@@ -307,16 +316,16 @@ export async function createSubscription(body: Record<string, unknown>): Promise
* Seven days is JMAP's ceiling and what Stalwart grants a new one; the server
* may shorten what is asked for, and whatever it keeps is what counts.
*/
export async function extendSubscription(id: Id, now: number = Date.now()): Promise<void> {
export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise<void> {
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
const res = await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
const res = await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
const err = res.notUpdated?.[id];
if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type));
}
export async function destroySubscriptions(ids: Id[]): Promise<void> {
export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise<void> {
if (!ids.length) return;
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
}
/**
@@ -328,18 +337,29 @@ export async function destroySubscriptions(ids: Id[]): Promise<void> {
*/
const ENDPOINT_KEY = "ihasmail:pushEndpoint";
export function registeredEndpoint(): string | null {
/*
* inbuxa MA-8: one per account, since each signed-in account on this device
* has its own subscription, and kept when switching between them (see
* KEEP_ON_SIGN_OUT in lib/storage) so a switch doesn't register them afresh.
* Without an account, the key from before: the account in front.
*/
function endpointKey(accountId?: Id | null): string {
return accountId ? `${ENDPOINT_KEY}:${accountId}` : ENDPOINT_KEY;
}
export function registeredEndpoint(accountId?: Id | null): string | null {
try {
return localStorage.getItem(ENDPOINT_KEY);
return localStorage.getItem(endpointKey(accountId)) ?? (accountId ? localStorage.getItem(ENDPOINT_KEY) : null);
} catch {
return null;
}
}
export function rememberEndpoint(endpoint: string | null): void {
export function rememberEndpoint(endpoint: string | null, accountId?: Id | null): void {
try {
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint);
else localStorage.removeItem(ENDPOINT_KEY);
const key = endpointKey(accountId);
if (endpoint) localStorage.setItem(key, endpoint);
else localStorage.removeItem(key);
} catch {
/* private mode: every start is then a fresh registration, which still works */
}
@@ -353,8 +373,8 @@ export function rememberEndpoint(endpoint: string | null): void {
* the client echoes it. A subscription left unverified looks registered and is
* silent, which is the confusing failure worth being explicit about.
*/
export async function verifySubscription(id: Id, verificationCode: string): Promise<void> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise<void> {
const res = await call<SetResponse<JmapPushSubscription>>(
"PushSubscription/set",
{ update: { [id]: { verificationCode } } },
[CAP.core, VAPID_CAP],
@@ -367,6 +387,20 @@ export async function destroySubscription(id: Id): Promise<void> {
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
}
/**
* inbuxa MA-8: remove this device's subscription in one account only, leaving
* the browser's push subscription and the switch alone -- for signing out of
* the account in front while others stay signed in and keep notifying.
*/
export async function unsubscribeAccount(call: JmapCall = frontCall, accountId?: Id | null): Promise<void> {
try {
await destroySubscriptions(mySubscriptions(await listSubscriptions(call), deviceClientId()).map((s) => s.id), call);
} catch {
/* signing out must not fail over this */
}
rememberEndpoint(null, accountId);
}
/** Remove every subscription this browser registered. Used when signing out. */
export async function unsubscribeThisDevice(): Promise<void> {
const mine = deviceClientId();
+110 -16
View File
@@ -5,12 +5,16 @@
* testable: everything here touches the browser's service worker and
* permission prompt, none of which exists under a test runner.
*/
import { CAP } from "@/jmap/client";
import { apiFetch, CAP } from "@/jmap/client";
import type { GetResponse, Id, Mailbox } from "@/jmap/types";
import { otherAccountCall } from "@/lib/notify/otherAccount";
import { setOtherAccountFacts, type OtherAccountFacts } from "@/lib/sw/swFacts";
import type { SignedInAccount } from "@/store/session";
import { withBase } from "../basePath";
import { SW_CACHE_NAME } from "../sw/swCache";
import { isDeviceTrusted } from "@/lib/storage";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import { ownInboxId } from "@/store/mail";
import {
applicationServerKey,
createSubscription,
@@ -18,6 +22,8 @@ import {
destroySubscriptions,
deviceClientId,
extendSubscription,
frontCall,
type JmapCall,
findSubscription,
listSubscriptions,
mySubscriptions,
@@ -29,6 +35,7 @@ import {
roomToMake,
setPushEnabledHere,
subscriptionPayload,
unsubscribeAccount,
unsubscribeThisDevice,
verifySubscription,
webPushAvailable,
@@ -48,7 +55,7 @@ export function listenForVerification(): void {
listening = true;
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {});
if (d?.type === "push-verification" && d.id && d.code) void verifyAnywhere(d.id, d.code);
});
void collectStoredVerification();
}
@@ -64,12 +71,44 @@ async function collectStoredVerification(): Promise<void> {
if (!hit) return;
const { id, code } = (await hit.json()) as { id?: string; code?: string };
await cache.delete(key);
if (id && code) await verifySubscription(id, code);
if (id && code) await verifyAnywhere(id, code);
} catch {
/* nothing waiting, or no cache: not a failure */
}
}
/**
* inbuxa MA-8: a verification code belongs to one account's subscription, and
* the worker doesn't say which: the account in front first, then each other
* signed-in account until one takes it.
*/
async function verifyAnywhere(id: Id, code: string): Promise<void> {
try {
await verifySubscription(id, code);
return;
} catch {
/* not the front account's */
}
for (const account of await otherAccounts()) {
try {
await verifySubscription(id, code, otherAccountCall(account.id));
return;
} catch {
/* not this one's either */
}
}
}
/** The signed-in accounts not in front, as the server lists them now. */
async function otherAccounts(): Promise<SignedInAccount[]> {
try {
const answer = await apiFetch<{ accounts: SignedInAccount[] }>("/api/auth/accounts");
return answer.accounts.filter((a) => !a.front);
} catch {
return [];
}
}
/**
* Subscribe this browser. Safe to call again: see `registerThisBrowser`.
*
@@ -97,6 +136,8 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
await registerThisBrowser(key);
setPushEnabledHere(true);
listenForVerification();
// inbuxa MA-8: and every other account signed in here
await registerOtherAccounts(key);
return { ok: true };
} catch (err) {
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
@@ -128,7 +169,23 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
* gave up there, leaving push off for good with the switch still saying it was
* on.
*/
async function registerThisBrowser(key: string): Promise<void> {
interface PushTarget {
call: JmapCall;
/** The account's mail account, which the subscription names. */
accountId: Id | null;
inboxId: Id | null;
/** Whose remembered endpoint to compare with: the account's own (MA-8). */
endpointOf: Id | null;
}
function frontTarget(): PushTarget {
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
const accountId = useSession.getState().ownAccountFor(CAP.mail);
return { call: frontCall, accountId, inboxId: ownInboxId(), endpointOf: accountId };
}
async function registerThisBrowser(key: string, target: PushTarget = frontTarget()): Promise<void> {
const { call } = target;
const reg = await navigator.serviceWorker.ready;
const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({
// Web Push requires it, and Chrome refuses a subscription without it.
@@ -136,34 +193,61 @@ async function registerThisBrowser(key: string): Promise<void> {
applicationServerKey: decodeApplicationServerKey(key),
}));
const deviceId = deviceClientId();
const subs = await listSubscriptions();
const subs = await listSubscriptions(call);
const mine = mySubscriptions(subs, deviceId);
const [newest, ...extra] = mine;
if (newest && registeredEndpoint() === sub.endpoint) {
if (extra.length) await destroySubscriptions(extra.map((s) => s.id));
if (newest && registeredEndpoint(target.endpointOf) === sub.endpoint) {
if (extra.length) await destroySubscriptions(extra.map((s) => s.id), call);
const at = newest.expires ? Date.parse(newest.expires) : Number.NaN;
if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return;
try {
await extendSubscription(newest.id);
await extendSubscription(newest.id, Date.now(), call);
return;
} catch {
/* not extendable: replaced below */
}
}
if (mine.length) await destroySubscriptions(mine.map((s) => s.id));
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), useMail.getState().roleId("inbox"));
if (mine.length) await destroySubscriptions(mine.map((s) => s.id), call);
const payload = subscriptionPayload(sub, target.accountId, target.inboxId);
try {
await createSubscription(payload);
await createSubscription(payload, call);
} catch (err) {
if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err;
const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId);
if (!room.length) throw err;
await destroySubscriptions(room);
await createSubscription(payload);
await destroySubscriptions(room, call);
await createSubscription(payload, call);
}
rememberEndpoint(sub.endpoint);
rememberEndpoint(sub.endpoint, target.endpointOf);
}
/**
* inbuxa MA-8: register this browser in every other account signed in here,
* each through its own session, and tell the worker who they are so their
* notifications say whose they are and their buttons act on the right mail.
* One account failing doesn't stop the rest; the next start tries it again.
*/
async function registerOtherAccounts(key: string): Promise<void> {
const facts: OtherAccountFacts[] = [];
for (const account of await otherAccounts()) {
if (!account.mailAccountId) continue;
const call = otherAccountCall(account.id);
try {
const boxes = await call<GetResponse<Mailbox>>(
"Mailbox/get",
{ accountId: account.mailAccountId, ids: null, properties: ["role"] },
[CAP.mail],
);
const roleId = (role: string) => boxes.list.find((m) => m.role === role)?.id ?? null;
await registerThisBrowser(key, { call, accountId: account.mailAccountId, inboxId: roleId("inbox"), endpointOf: account.mailAccountId });
facts.push({ accountId: account.mailAccountId, sessionId: account.id, username: account.username, archiveId: roleId("archive"), inboxId: roleId("inbox") });
} catch {
/* this one waits for the next start */
}
}
await setOtherAccountFacts(facts);
}
/**
@@ -189,16 +273,26 @@ export async function renewWebPush(): Promise<void> {
// subscription is close to expiring, missing, or duplicated.
await registerThisBrowser(key);
listenForVerification();
await registerOtherAccounts(key);
} catch {
/* offline, or the server said no: the next start tries again */
}
}
/** Remove this browser's subscription, at the browser and at the server. */
/** Remove this browser's subscription, at the browser and at the server, in every signed-in account. */
export async function disableWebPush(): Promise<void> {
await unsubscribeOtherAccounts();
await unsubscribeThisDevice();
}
/** inbuxa MA-8: remove this device's subscription from every account not in front. */
export async function unsubscribeOtherAccounts(): Promise<void> {
for (const account of await otherAccounts()) {
await unsubscribeAccount(otherAccountCall(account.id), account.mailAccountId);
}
await setOtherAccountFacts([]);
}
/**
* Whether *this browser* has a subscription registered at the server.
*
+81
View File
@@ -0,0 +1,81 @@
/**
* inbuxa MA-8: new mail in the accounts not in front.
*
* The webmail server answers each one's Inbox unread count through that
* account's own session (/api/auth/accounts/unread). The menu shows the counts;
* when one rises while the app is open, a desktop notification names the
* account, so mail for support@ isn't missed while someone works in their own.
*
* Only while a tab is open, and only where background notifications are off:
* with them on, each account has its own Web Push subscription and the worker
* notifies (lib/notify/webpushEnable, public/sw.js).
*/
import { useEffect } from "react";
import { create } from "zustand";
import { apiFetch } from "@/jmap/client";
import { showNotification } from "@/lib/notify/notify";
import { pushEnabledHere } from "@/lib/notify/webpush";
import { t } from "@/lib/i18n";
import { useSession } from "@/store/session";
import { useSettings } from "@/store/settings";
/** How often to ask. The server keeps each answer a minute. */
export const POLL_MS = 2 * 60_000;
interface OtherUnreadState {
/** Unread count per session id; absent until first asked, or when unknown. */
unread: Record<string, number>;
set(unread: Record<string, number>): void;
}
export const useOtherUnread = create<OtherUnreadState>((set) => ({
unread: {},
set: (unread) => set({ unread }),
}));
/**
* Which accounts gained unread mail since the last answer. An account seen for
* the first time is not "new": its mail was already there when it was added.
*/
export function risen(before: Record<string, number>, after: Record<string, number>): string[] {
return Object.entries(after)
.filter(([id, n]) => id in before && n > before[id]!)
.map(([id]) => id);
}
export async function pollOtherUnread(): Promise<void> {
const answer = await apiFetch<{ accounts: { id: string; unread: number | null }[] }>("/api/auth/accounts/unread");
const next: Record<string, number> = {};
for (const a of answer.accounts) if (typeof a.unread === "number") next[a.id] = a.unread;
const before = useOtherUnread.getState().unread;
useOtherUnread.getState().set(next);
if (!useSettings.getState().settings.desktopNotifications) return;
// With background notifications on here, the worker tells about these
// accounts already (MA-8 part 2): the counts stay, a second telling doesn't
if (pushEnabledHere()) return;
const names = new Map(useSession.getState().signedIn.map((a) => [a.id, a.username]));
for (const id of risen(before, next)) {
const name = names.get(id);
if (!name) continue;
showNotification(t("New mail for {name}", { name }), {
body: t("Unread in the Inbox: {count}", { count: next[id]! }),
tag: `other-account-${id}`,
onClick: () => void useSession.getState().switchTo(id),
});
}
}
/** Keeps the counts fresh while more than one account is signed in. */
export function useOtherAccountsUnread(): void {
const others = useSession((s) => s.signedIn.filter((a) => !a.front).length);
useEffect(() => {
if (others === 0) {
useOtherUnread.getState().set({});
return;
}
const tick = () => void pollOtherUnread().catch(() => undefined);
tick();
const timer = setInterval(tick, POLL_MS);
return () => clearInterval(timer);
}, [others]);
}
+28 -10
View File
@@ -34,6 +34,9 @@ let inFlight: Promise<void> | null = null;
/** Nothing is pushed before the first load has settled, or we would race it. */
let armed = false;
let loadedFor: string | null = null;
let loading: Promise<void> | null = null;
/** Bumped by every new load and every sign-out; a load checks it is still the latest. */
let generation = 0;
let listenersBound = false;
export function settingsSyncAvailable(): boolean {
@@ -64,21 +67,34 @@ export async function loadRemoteSettings(): Promise<Record<string, unknown> | nu
}
/**
* Has this account's settings file already been read on this page load?
* Load this account's settings, once per page load, however many times the
* caller mounts.
*
* Claims the account as a side effect, so two callers cannot both start a
* read. The subtree that does the reading is keyed on the language version
* and so is deliberately remounted whenever somebody picks a language;
* without this the remount re-reads a file written before the change and
* applies it, putting the old language back.
* The subtree that does the reading is keyed on the language version, so it
* is remounted whenever the language changes -- including when the settings
* file itself picks one. Tying the load to a mount meant that remount canceled
* it halfway: the new mount saw the account already claimed and skipped the
* load, and nothing armed the pushes, so every later change was silently
* dropped (Gitea issue #23). Now the load belongs to the account, not the
* mount: every mount waits on the same promise, and the load runs to the end.
*
* Re-reading on a remount is still wrong -- it would apply a file written
* before the change and undo it -- which is why it is shared, not repeated.
*
* `isCurrent` turns false once the session that started the load signs out,
* so a load overtaken by a sign-out stops instead of applying stale settings.
* Cleared by `stopSettingsSync`, so signing out and back in reads again.
*/
export function settingsAlreadyLoadedFor(accountId: string | null | undefined): boolean {
if (!accountId) return true;
if (loadedFor === accountId) return true;
export function loadSettingsOnce(
accountId: string | null | undefined,
load: (isCurrent: () => boolean) => Promise<void>,
): Promise<void> {
if (!accountId) return Promise.resolve();
if (loadedFor === accountId && loading) return loading;
loadedFor = accountId;
return false;
const mine = ++generation;
loading = load(() => generation === mine);
return loading;
}
/** Allow pushes. Called once the first load has settled, either way. */
@@ -93,6 +109,8 @@ export function armSettingsSync(): void {
export function stopSettingsSync(): void {
armed = false;
loadedFor = null;
loading = null;
generation++;
pending = null;
if (timer !== null) {
window.clearTimeout(timer);
+53
View File
@@ -0,0 +1,53 @@
/**
* Mail other people let the reader into (multi-account spec, MA-A): a group's
* mailbox, folders someone shared, or a shared mailbox an administrator
* assigned them to (MA-S).
*
* Either one arrives as another account in the session, `isPersonal: false`.
* That alone proves nothing about mail -- the server advertises every
* capability on any account it lists, so a colleague who shared one calendar
* shows up with mail too. What does prove it is asking: an account whose
* `Mailbox/get` answers with at least one mailbox has mail the reader can
* open, and only those are offered.
*
* A shared mailbox needs no asking: the server marks it, as a delegation of
* kind `sharedMailbox`, and it is mail by definition. A locked account handed
* to the reader (AL-7) is listed by `delegation.ts` instead, and left out
* here so it is never offered twice.
*/
import { CAP, client } from "@/jmap/client";
import type { GetResponse, Id, JmapSession, Mailbox } from "@/jmap/types";
import { delegationOf } from "@/lib/delegation";
export interface SharedMailAccount {
id: Id;
name: string;
}
type SessionLike = Pick<JmapSession, "accounts">;
/** Accounts that might hold mail for the reader, by name; see the note above. */
export function sharedMailCandidates(session: SessionLike | null): SharedMailAccount[] {
if (!session) return [];
return Object.entries(session.accounts)
.filter(([id, account]) => account.isPersonal === false && CAP.mail in (account.accountCapabilities ?? {}) && delegationOf(session, id)?.kind !== "lock")
.map(([id, account]) => ({ id, name: account.name }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/** The candidates that answer with at least one mailbox. One that fails is left out. */
export async function findSharedMail(session: SessionLike | null): Promise<SharedMailAccount[]> {
const candidates = sharedMailCandidates(session);
const answers = await Promise.all(
candidates.map((account) =>
delegationOf(session, account.id)?.kind === "sharedMailbox"
? Promise.resolve(account)
: client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId: account.id, ids: null, properties: ["id"] }).then(
(res) => (res.list.length > 0 ? account : null),
() => null,
),
),
);
return answers.filter((a): a is SharedMailAccount => a !== null);
}
+4 -1
View File
@@ -4,5 +4,8 @@
* The AGPL asks whoever runs a modified version to offer *that* version's
* source. The server says where its own lives, via SOURCE_URL; this is only the
* fallback for when it has not been asked yet, or has nothing to say.
*
* ihasmail-inbuxa: INBUXA runs a modified ihasmail, so the offer is INBUXA's
* fork and not the project it came from.
*/
export const DEFAULT_SOURCE_URL = "https://github.com/Coffey-Labs/ihasmail";
export const DEFAULT_SOURCE_URL = "https://git.coffeylabs.org/inbuxa/inbuxa-webmail";
+3
View File
@@ -87,6 +87,9 @@ function ownKeys(): string[] {
export function clearSignedInData(): void {
for (const key of ownKeys()) {
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
// inbuxa MA-8: each account's registered push endpoint, which is not mail
// and is cleared with its subscription (webpush.ts)
if (key.startsWith("pushEndpoint:")) continue;
removeKey(key);
}
}
+8
View File
@@ -44,6 +44,14 @@ describe("the worker's briefing", () => {
expect(facts.archiveId).toBe("mb-archive");
});
it("names the inbox a notification opens in", async () => {
// The route takes a mailbox id. The worker used to put the word `inbox`
// there, and every click landed on "That folder no longer exists".
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive", "mb-inbox");
expect(written(store).inboxId).toBe("mb-inbox");
});
it("carries the worker's text in the language the tab is in", async () => {
// The worker has no catalog. Everything it will say has to be said here
// first, or a German reader gets English buttons on their lock screen.
+30 -2
View File
@@ -16,6 +16,7 @@
* was installed, which is the same condition background notifications already
* carry — a push subscription has to be renewed from a tab too.
*/
import { currentAppName } from "@/lib/brand";
import { withBase } from "../basePath";
import { SW_CACHE_NAME } from "./swCache";
import { t } from "../i18n";
@@ -27,6 +28,13 @@ export interface WorkerFacts {
accountId: string;
/** Where Archive files to; null where the account has no archive folder. */
archiveId: string | null;
/** The inbox a notification opens in; the route names a mailbox by id. */
inboxId?: string | null;
/**
* inbuxa MA-8: the other accounts signed in here, so a push for one of them
* says whose it is and its buttons act through that account's session.
*/
others?: OtherAccountFacts[];
/** The worker's own user-visible text, in the language this tab is in. */
strings: {
newMail: string;
@@ -46,18 +54,38 @@ export interface WorkerFacts {
* reading in a week's time. Rewriting it is one cache put; there is nothing to
* gain by working out whether it differs.
*/
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise<void> {
export interface OtherAccountFacts {
accountId: string;
sessionId: string;
username: string;
archiveId: string | null;
inboxId?: string | null;
}
let lastFront: { accountId: string | null; archiveId: string | null; inboxId: string | null } = { accountId: null, archiveId: null, inboxId: null };
let others: OtherAccountFacts[] = [];
/** inbuxa MA-8: record the other accounts and write the briefing again with them. */
export async function setOtherAccountFacts(list: OtherAccountFacts[]): Promise<void> {
others = list;
await publishWorkerFacts(lastFront.accountId, lastFront.archiveId, lastFront.inboxId);
}
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null, inboxId: string | null = null): Promise<void> {
lastFront = { accountId, archiveId, inboxId };
if (typeof caches === "undefined" || !accountId) return;
const facts: WorkerFacts = {
accountId,
archiveId,
inboxId,
others,
strings: {
newMail: t("New mail"),
newMessage: t("New message"),
noSubject: t("(no subject)"),
archive: t("Archive"),
markRead: t("Mark as read"),
failed: t("Could not do that — open ihasmail and try again"),
failed: t("Could not do that — open {app} and try again", { app: currentAppName() }),
},
};
try {
+13
View File
@@ -387,3 +387,16 @@ describe("the containment that mail CSS cannot override", () => {
expect(rule).toMatch(/contain\s*:\s*layout/);
});
});
describe("mail wider than the reading pane", () => {
/*
* The root keeps `contain: content`, which clips what overflows, so without
* a scroll the right side of a min-width table was simply gone, worst on a
* phone (Gitea issue #24). jsdom does no layout, so this pins the rule.
*/
it("scrolls sideways instead of being cut off", () => {
const root = EMAIL_BASE_CSS.match(/\.ihm-email-root \{([^}]*)\}/)?.[1] ?? "";
expect(root).toMatch(/contain:\s*content/);
expect(root).toMatch(/overflow-x:\s*auto/);
});
});
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more