Compare 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
61 changed files with 3064 additions and 149 deletions

No files matched your search

+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:
+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
+6 -5
View File
@@ -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
@@ -212,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?
+1 -1
View File
@@ -107,4 +107,4 @@ keep up. Nothing here is pushed there.
## License
Copyright (C) 2026 Coffey Labs. AGPL-3.0-or-later; see [LICENSE](LICENSE).
Copyright (C) 2026 Coffey Labs LLC. AGPL-3.0-or-later; see [LICENSE](LICENSE).
+1 -1
View File
@@ -15,7 +15,7 @@ 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:
+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.",
};
+281 -2
View File
@@ -45,6 +45,7 @@ import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, passwo
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 } };
@@ -330,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);
@@ -406,8 +547,19 @@ export function createApp(basePath = config.basePath): Hono<Env> {
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" });
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) {
@@ -452,6 +604,11 @@ export function createApp(basePath = config.basePath): Hono<Env> {
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));
@@ -473,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 {
@@ -536,6 +693,12 @@ 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.
@@ -606,7 +769,123 @@ 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 });
});
+24 -1
View File
@@ -23,9 +23,25 @@ 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 (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(":");
@@ -83,7 +99,14 @@ export const server = createServer(async (req, res) => {
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") {
+20
View File
@@ -217,3 +217,23 @@ test("push keeps a credential that renews itself", async () => {
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]"]);
});
+9 -4
View File
@@ -97,6 +97,8 @@ interface Pending {
base: string;
username: string;
remember: boolean;
/** MA-B: signing in a second account beside the one in front. */
adding: boolean;
createdAt: number;
}
@@ -114,13 +116,13 @@ function challengeOf(verifier: string): string {
* 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 }): Promise<{ location: string; state: string }> {
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, createdAt: Date.now() });
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");
@@ -131,6 +133,9 @@ export async function start(params: { username: string; base: string; remember:
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 };
}
@@ -177,7 +182,7 @@ export class SignInError extends Error {
* 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 }> {
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.");
@@ -192,7 +197,7 @@ export async function finish(params: { state: string; boundState: string | undef
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 };
return { tokens, base: p.base, username: p.username, remember: p.remember, adding: p.adding };
}
/**
+80 -6
View File
@@ -386,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" },
@@ -456,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
@@ -464,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.
@@ -502,8 +513,7 @@ self.addEventListener("push", (event) => {
// 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,
@@ -515,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.
*
@@ -535,7 +609,7 @@ 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",
+9 -6
View File
@@ -1,6 +1,6 @@
import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react";
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
import { useSession } from "@/store/session";
import { useSession, useViewingDelegation } from "@/store/session";
import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail";
import { scheduleSupported, useScheduled } from "@/store/scheduled";
import { useContacts } from "@/store/contacts";
@@ -311,12 +311,14 @@ function AuthedApp() {
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
// 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(viewingName ? `🔒 ${viewingName} · ${appName}` : appName);
setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName);
setUnreadBadge(inboxUnread);
}, [inboxUnread, appName, viewingName]);
}, [inboxUnread, appName, viewingName, lockedInView]);
/*
* Leave the service worker its briefing.
@@ -328,13 +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(() => {
// 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);
}, [accountId, archiveId, languageVersion, viewing]);
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,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);
}
});
});
+9 -2
View File
@@ -13,6 +13,12 @@ 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. */
@@ -38,19 +44,20 @@ export function delegationOf(session: SessionLike | null, accountId: Id | 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. */
/** 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 ? { id, name: account.name, delegation } : null;
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));
@@ -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);
});
});
+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();
+109 -16
View File
@@ -5,7 +5,11 @@
* 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";
@@ -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,35 +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));
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), ownInboxId());
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);
}
/**
@@ -190,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]);
}
+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);
}
+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.
+28 -1
View File
@@ -28,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;
@@ -47,11 +54,31 @@ 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"),
+1 -1
View File
@@ -4,7 +4,7 @@
* The rule this replaces was `subject.replace(/[^\w.-]+/g, "_")`, and `\w`
* without the `u` flag is ASCII: every character of a Russian, Japanese or
* Chinese subject failed the class, so those messages downloaded as a row of
* underscores. ihasmail ships in nine languages besides English, so the
* underscores. ihasmail ships in ten languages besides English, so the
* subjects it handled worst were most of the world's.
*
* What is actually unsafe in a filename is a much shorter list than "not
+11
View File
@@ -651,6 +651,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} weitere",
"Contacts": "Kontakte",
"Collected": "Gesammelt",
"Save people you write to as contacts": "Personen, denen Sie schreiben, als Kontakte speichern",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Alle, an die Sie senden und die noch keine Kontakte sind, werden dem Adressbuch „Gesammelt“ hinzugefügt und auf jedem Gerät vorgeschlagen. Ihre eigenen Adressen werden nie hinzugefügt.",
"Contacts are not available": "Kontakte sind nicht verfügbar",
"New contact": "Neuer Kontakt",
"Edit contact": "Kontakt bearbeiten",
@@ -732,6 +738,8 @@ export const catalog: Catalog = {
// ── Settings ───────────────────────────────────────────────────────
"Settings": "Einstellungen",
"Show folder list": "Ordnerliste einblenden",
"Hide folder list": "Ordnerliste ausblenden",
"All settings": "Alle Einstellungen",
"Sections": "Bereiche",
"General": "Allgemein",
@@ -1355,6 +1363,9 @@ export const catalog: Catalog = {
"Send anyway": "Trotzdem senden",
"Send canceled — the message is back in Drafts": "Senden abgebrochen – die Nachricht liegt wieder in den Entwürfen",
"Send failed: {error}": "Senden fehlgeschlagen: {error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Es konnte nicht geprüft werden, ob diese Nachricht schon gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Es konnte nicht bestätigt werden, ob diese Nachricht gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
"This message had already been sent, so it wasn't sent again.": "Diese Nachricht war bereits gesendet und wurde nicht noch einmal gesendet.",
"Send invites": "Einladungen senden",
"Send scheduled for {when}": "Senden geplant für {when}",
"Send without a subject?": "Ohne Betreff senden?",
+11
View File
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} más",
"Contacts": "Contactos",
"Collected": "Recopilados",
"Save people you write to as contacts": "Guardar como contactos a las personas a las que escribes",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todas las personas a las que envías y que aún no son contactos se añaden a la libreta Recopilados, para que se sugieran en todos tus dispositivos. Tus propias direcciones nunca se añaden.",
"Contacts are not available": "Los contactos no están disponibles",
"New contact": "Contacto nuevo",
"Edit contact": "Editar el contacto",
@@ -727,6 +733,8 @@ export const catalog: Catalog = {
// ── Settings ───────────────────────────────────────────────────────
"Settings": "Configuración",
"Show folder list": "Mostrar lista de carpetas",
"Hide folder list": "Ocultar lista de carpetas",
"All settings": "Toda la configuración",
"Sections": "Secciones",
"General": "General",
@@ -1328,6 +1336,9 @@ export const catalog: Catalog = {
"Send anyway": "Enviar de todos modos",
"Send canceled — the message is back in Drafts": "Envío cancelado: el mensaje ha vuelto a Borradores",
"Send failed: {error}": "No se pudo enviar: {error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "No se pudo comprobar si este mensaje ya se envió. Revisa Enviados antes de volver a enviarlo.",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "No se pudo confirmar si este mensaje se envió. Revisa Enviados antes de volver a enviarlo.",
"This message had already been sent, so it wasn't sent again.": "Este mensaje ya se había enviado, así que no se ha vuelto a enviar.",
"Send invites": "Enviar invitaciones",
"Send scheduled for {when}": "Envío programado para {when}",
"Send without a subject?": "¿Enviar sin asunto?",
+11
View File
@@ -648,6 +648,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} autres",
"Contacts": "Contacts",
"Collected": "Collectés",
"Save people you write to as contacts": "Enregistrer comme contacts les personnes à qui vous écrivez",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Toute personne à qui vous écrivez et qui n’est pas encore un contact est ajoutée au carnet Collectés, pour vous être proposée sur tous vos appareils. Vos propres adresses ne sont jamais ajoutées.",
"Contacts are not available": "Les contacts ne sont pas disponibles",
"New contact": "Nouveau contact",
"Edit contact": "Modifier le contact",
@@ -733,6 +739,8 @@ export const catalog: Catalog = {
// ── Settings ───────────────────────────────────────────────────────
"Settings": "Paramètres",
"Show folder list": "Afficher la liste des dossiers",
"Hide folder list": "Masquer la liste des dossiers",
"All settings": "Tous les paramètres",
"Sections": "Sections",
"General": "Général",
@@ -1333,6 +1341,9 @@ export const catalog: Catalog = {
"Send anyway": "Envoyer quand même",
"Send canceled — the message is back in Drafts": "Envoi annulé : le message est de retour dans les brouillons",
"Send failed: {error}": "Échec de l’envoi : {error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Impossible de vérifier si ce message a déjà été envoyé. Consultez Envoyés avant de le renvoyer.",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Impossible de confirmer l’envoi de ce message. Consultez Envoyés avant de le renvoyer.",
"This message had already been sent, so it wasn't sent again.": "Ce message avait déjà été envoyé, il n’a donc pas été renvoyé.",
"Send invites": "Envoyer les invitations",
"Send scheduled for {when}": "Envoi programmé pour {when}",
"Send without a subject?": "Envoyer sans objet ?",
+11
View File
@@ -642,6 +642,12 @@ export const catalog: Catalog = {
"+{n} more": "他 {n} 件",
"Contacts": "連絡先",
"Collected": "収集したアドレス",
"Save people you write to as contacts": "メールを送った相手を連絡先に保存する",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "まだ連絡先にない送信先は「収集したアドレス」アドレス帳に追加され、どのデバイスでも候補に表示されます。自分のアドレスは追加されません。",
"Contacts are not available": "連絡先は利用できません",
"New contact": "新しい連絡先",
"Edit contact": "連絡先を編集",
@@ -727,6 +733,8 @@ export const catalog: Catalog = {
// ── Settings ───────────────────────────────────────────────────────
"Settings": "設定",
"Show folder list": "フォルダー一覧を表示",
"Hide folder list": "フォルダー一覧を隠す",
"All settings": "すべての設定",
"Sections": "セクション",
"General": "一般",
@@ -1336,6 +1344,9 @@ export const catalog: Catalog = {
"Send anyway": "このまま送信",
"Send canceled — the message is back in Drafts": "送信を取り消しました。メールは下書きに戻っています",
"Send failed: {error}": "送信できませんでした: {error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "このメッセージが送信済みかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "このメッセージが送信されたかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
"This message had already been sent, so it wasn't sent again.": "このメッセージはすでに送信されていたため、再送信しませんでした。",
"Send invites": "招待を送信",
"Send scheduled for {when}": "{when} に送信を予約しました",
"Send without a subject?": "件名なしで送信しますか?",
+11
View File
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} meer",
"Contacts": "Contacten",
"Collected": "Verzameld",
"Save people you write to as contacts": "Mensen aan wie je schrijft als contact opslaan",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Iedereen aan wie je mailt en die nog geen contact is, wordt toegevoegd aan het adresboek Verzameld, zodat die op elk apparaat wordt voorgesteld. Je eigen adressen worden nooit toegevoegd.",
"Contacts are not available": "Contacten zijn niet beschikbaar",
"New contact": "Nieuw contact",
"Edit contact": "Contact bewerken",
@@ -728,6 +734,8 @@ export const catalog: Catalog = {
// ── Settings ───────────────────────────────────────────────────────
"Settings": "Instellingen",
"Show folder list": "Mappenlijst tonen",
"Hide folder list": "Mappenlijst verbergen",
"All settings": "Alle instellingen",
"Sections": "Onderdelen",
"General": "Algemeen",
@@ -1328,6 +1336,9 @@ export const catalog: Catalog = {
"Send anyway": "Toch verzenden",
"Send canceled — the message is back in Drafts": "Verzenden geannuleerd — het bericht staat weer bij Concepten",
"Send failed: {error}": "Verzenden mislukt: {error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Kon niet controleren of dit bericht al is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Kon niet bevestigen of dit bericht is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
"This message had already been sent, so it wasn't sent again.": "Dit bericht was al verzonden en is daarom niet opnieuw verstuurd.",
"Send invites": "Uitnodigingen verzenden",
"Send scheduled for {when}": "Verzenden gepland voor {when}",
"Send without a subject?": "Verzenden zonder een onderwerp?",
+11
View File
@@ -646,6 +646,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n} outros",
"Contacts": "Contatos",
"Collected": "Coletados",
"Save people you write to as contacts": "Salvar como contatos as pessoas para quem você escreve",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todos para quem você envia e que ainda não são contatos são adicionados à agenda Coletados, para serem sugeridos em todos os dispositivos. Seus próprios endereços nunca são adicionados.",
"Contacts are not available": "Os contatos não estão disponíveis",
"New contact": "Novo contato",
"Edit contact": "Editar o contato",
@@ -730,6 +736,8 @@ export const catalog: Catalog = {
// ── Settings ───────────────────────────────────────────────────────
"Settings": "Configurações",
"Show folder list": "Mostrar lista de pastas",
"Hide folder list": "Ocultar lista de pastas",
"All settings": "Todas as configurações",
"Sections": "Seções",
"General": "Geral",
@@ -1331,6 +1339,9 @@ export const catalog: Catalog = {
"Send anyway": "Enviar mesmo assim",
"Send canceled — the message is back in Drafts": "Envio cancelado — a mensagem voltou para Rascunhos",
"Send failed: {error}": "Falha ao enviar: {error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Não foi possível verificar se esta mensagem já foi enviada. Confira Enviados antes de enviá-la novamente.",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Não foi possível confirmar se esta mensagem foi enviada. Confira Enviados antes de enviá-la novamente.",
"This message had already been sent, so it wasn't sent again.": "Esta mensagem já tinha sido enviada, então não foi enviada de novo.",
"Send invites": "Enviar convites",
"Send scheduled for {when}": "Envio agendado para {when}",
"Send without a subject?": "Enviar sem assunto?",
+11
View File
@@ -645,6 +645,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n}",
"Contacts": "Контакты",
"Collected": "Собранные",
"Save people you write to as contacts": "Сохранять адресатов как контакты",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Все, кому вы пишете и кого ещё нет в контактах, добавляются в адресную книгу «Собранные» и предлагаются на всех устройствах. Ваши собственные адреса не добавляются.",
"Contacts are not available": "Контакты недоступны",
"New contact": "Новый контакт",
"Edit contact": "Изменить контакт",
@@ -730,6 +736,8 @@ export const catalog: Catalog = {
// ── Settings ───────────────────────────────────────────────────────
"Settings": "Настройки",
"Show folder list": "Показать список папок",
"Hide folder list": "Скрыть список папок",
"All settings": "Все настройки",
"Sections": "Разделы",
"General": "Общие",
@@ -1330,6 +1338,9 @@ export const catalog: Catalog = {
"Send anyway": "Всё равно отправить",
"Send canceled — the message is back in Drafts": "Отправка отменена — письмо вернулось в черновики",
"Send failed: {error}": "Не удалось отправить: {error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не удалось проверить, было ли это письмо уже отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не удалось подтвердить, было ли это письмо отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
"This message had already been sent, so it wasn't sent again.": "Это письмо уже было отправлено, поэтому повторно оно не отправлялось.",
"Send invites": "Отправить приглашения",
"Send scheduled for {when}": "Отправка запланирована на {when}",
"Send without a subject?": "Отправить без темы?",
+99 -22
View File
@@ -48,7 +48,6 @@ export const catalog: Catalog = {
"A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar’s own sidebar without turning it off here.": "Kişi kartlarınızdaki doğum günlerinden türetilen özel bir takvim. Hiçbir yere bir şey yazılmaz — tarihler kartlarda kalır ve kişi silindiğinde veya doğum günü temizlendiğinde etkinlik kaybolur. Buradan kapatılmadan da takvimin kendi kenar çubuğundan gizlenebilir.",
"A calendar published at a URL — a timetable, a rota, a public holiday list. It is read-only, refreshed when you open the calendar, and never stored: the events are fetched and kept only for as long as this tab is open.": "Bir URL'de yayımlanan bir takvim — bir ders programı, nöbet çizelgesi veya resmi tatil listesi. Salt okunurdur, takvimi açtığınızda yenilenir ve asla saklanmaz: etkinlikler yalnızca bu sekme açık olduğu sürece getirilir ve tutulur.",
"A denial wins over anything allowed, here or on a role this one builds on. You can only allow permissions you hold yourself.": "Bir ret, burada veya üzerine inşa edildiği bir rolde izin verilen her şeyden üstündür. Yalnızca bizzat sahip olduğunuz izinlere onay verebilirsiniz.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "JMAP üzerine inşa edilmiş, {server} için hızlı, samimi ve açık kaynaklı bir web posta.",
"A group needs an address.": "Bir grubun bir adrese ihtiyacı vardır.",
"A link whose text names one domain and whose destination is another is always flagged, even where the destination is trusted — being trusted is not the same as being the place the text claimed.": "Metninde bir etki alanı adı yazan ancak hedefi başka bir etki alanı olan bağlantılar, hedef güvenilir olsa bile her zaman işaretlenir — güvenilir olmak metnin iddia ettiği yer olmakla aynı şey değildir.",
"A list needs an address.": "Bir listenin bir adrese ihtiyacı vardır.",
@@ -147,7 +146,6 @@ export const catalog: Catalog = {
"An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts toward its limits, and Administrator means administrator of that tenant.": "Bir hesap, alan adının bulunduğu kiracıda yer alabilir. Bir kiracı içinde, kiracının rolüyle sınırlandırılır ve limitlerine dahil edilir; Yönetici ise o kiracının yöneticisi anlamına gelir.",
"An account needs an address.": "Bir hesabın bir adrese ihtiyacı vardır.",
"An empty tenant can be deleted.": "Boş bir kiracı silinebilir.",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Bir görselin https adresi veya data URL'si. Stalwart logo gösterdiği yerlerde bunu kiracının kullanıcılarına gösterir.",
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "Gönderenin sunucusundan yüklenen bir görsel, iletinin ne zaman ve kabaca nereden açıldığını gönderene bildirir. Onaylanan görseller tarayıcı yerine {app}'in kendi sunucusu tarafından getirilir, böylece gönderen bunların hiçbirini öğrenemez.",
"An occurrence cannot be moved to another calendar on its own": "Bir yineleme tek başına başka bir takvime taşınamaz",
"Another name for this domain": "Bu alan adı için başka bir ad",
@@ -271,6 +269,9 @@ export const catalog: Catalog = {
"Contact options": "Kişi seçenekleri",
"Contact saved": "Kişi kaydedildi",
"Contacts": "Kişiler",
"Collected": "Toplananlar",
"Save people you write to as contacts": "Yazdığınız kişileri kişi olarak kaydet",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Gönderdiğiniz ve henüz kişileriniz arasında olmayan herkes Toplananlar adres defterine eklenir, böylece her cihazda önerilir. Kendi adresleriniz asla eklenmez.",
"Contacts are not available": "Kişiler kullanılamıyor",
"Continue": "Devam et",
"Conversation moved to {folder}": "Yazışma {folder} klasörüne taşındı",
@@ -368,8 +369,6 @@ export const catalog: Catalog = {
"Defaults for the calendar views and new events.": "Takvim görünümleri ve yeni etkinlikler için varsayılanlar.",
"Delete": "Sil",
"Delete (#)": "Sil (#)",
"Delete account": "Hesabı sil",
"Delete account…": "Hesabı sil…",
"Delete all spam now": "Tüm spam'i şimdi sil",
"Delete category": "Kategoriyi sil",
"Delete failed: {error}": "Silme başarısız: {error}",
@@ -403,7 +402,6 @@ export const catalog: Catalog = {
"Deleted {address}": "{address} silindi",
"Deleted {name}": "{name} silindi",
"Deletes the group and its mailbox. Its members' own accounts stay.": "Grubu ve posta kutusunu siler. Üyelerin kendi hesapları kalır.",
"Deletes the mailbox and everything in it.": "Posta kutusunu ve içindeki her şeyi siler.",
"Deletes the whole series. This cannot be undone.": "Tüm diziyi siler. Bu işlem geri alınamaz.",
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "Bir grubu silmek önce üyelerini gruptan çıkarır ve rolünüz bu üyelerin hesaplarını değiştiremez.",
"Deleting spam is permanent — it does not go to Deleted Items first.": "Spam silme kalıcıdır; önce Çöp Kutusuna gitmez.",
@@ -413,7 +411,6 @@ export const catalog: Catalog = {
"Deny": "Reddet",
"Description": "Açıklama",
"Desktop notifications while {app} is open": "{app} açıkken masaüstü bildirimleri",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Ayrıntılı metrikler, teslimat kuyruğu, günlükler ve sunucu ayarları Stalwart'ın kendi yönetim panelindedir.",
"Details": "Ayrıntılar",
"Device": "Cihaz",
"Did you forget the attachment?": "Eki unuttunuz mu?",
@@ -425,7 +422,6 @@ export const catalog: Catalog = {
"Dismiss": "Kapat",
"Display density": "Görüntü yoğunluğu",
"Display name": "Görünen ad",
"Documentation": "Belgeler",
"Does not repeat": "Tekrarlanmaz",
"Domain": "Alan Adı",
"Domain to add": "Eklenecek alan adı",
@@ -853,7 +849,6 @@ export const catalog: Catalog = {
"Only the beginning is shown — download the file for the rest.": "Yalnızca başlangıç gösteriliyor — geri kalanı için dosyayı indirin.",
"Only when it has unread mail": "Yalnızca okunmamış posta olduğunda",
"Open": "Aç",
"Open Stalwart admin": "Stalwart yönetimini aç",
"Open a link to {domain}?": "{domain} bağlantısı açılsın mı?",
"Open draft": "Taslağı aç",
"Open in calendar": "Takvimde aç",
@@ -1067,6 +1062,9 @@ export const catalog: Catalog = {
"Send at": "Şu saatte gönder",
"Send canceled — the message is back in Drafts": "Gönderim iptal edildi — ileti tekrar Taslaklar içinde",
"Send failed: {error}": "Gönderme başarısız: {error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Bu iletinin daha önce gönderilip gönderilmediği denetlenemedi. Yeniden göndermeden önce Gönderilenler klasörüne bakın.",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Bu iletinin gönderilip gönderilmediği doğrulanamadı. Yeniden göndermeden önce Gönderilenler klasörüne bakın.",
"This message had already been sent, so it wasn't sent again.": "Bu ileti zaten gönderilmişti, bu yüzden yeniden gönderilmedi.",
"Send invitation emails to guests": "Konuklara e-posta ile davetiye gönder",
"Send invites": "Davetleri gönder",
"Send now instead": "Bunun yerine şimdi gönder",
@@ -1090,6 +1088,8 @@ export const catalog: Catalog = {
"Set on this role": "Bu role ayarlandı",
"Set password": "Şifre belirle",
"Settings": "Ayarlar",
"Show folder list": "Klasör listesini göster",
"Hide folder list": "Klasör listesini gizle",
"Settings imported": "Ayarlar içe aktarıldı",
"Settings reset to defaults": "Ayarlar varsayılanlara sıfırlandı",
"Settings → Filters & rules": "Ayarlar → Filtreler ve kurallar",
@@ -1154,13 +1154,6 @@ export const catalog: Catalog = {
"Source": "Kaynak",
"Spam filter": "Spam filtresi",
"Spell check while typing": "Yazarken yazım denetimi yap",
"Stalwart": "Stalwart",
"Stalwart Mail Server": "Stalwart Mail Server",
"Stalwart does not publish its version number to mail clients, so {app} reports the edition where the server gives one. {app} requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart sürüm numarasını posta istemcilerine yayımlamaz, bu nedenle {app} sunucunun belirttiği sürümü bildirir. {app}, 0.16 veya daha yeni bir sürüm gerektirir ve oturum açma daha eski sürümleri reddeder.",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart bu rolü varsayılan olarak {kinds} grubuna verir. Buradaki bir değişiklik bu şekilde sahip olan herkese ulaşır.",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart bu rolü varsayılan olarak verir, bu nedenle silinemez. Önce Stalwart'ın kendi yönetim panelindeki varsayılanları değiştirin.",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart bir limitin izin verdiğinden fazlasını oluşturmayı reddeder. Boş bir alan limit olmadığı anlamına gelir.",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Stalwart'ın izin listesi yüklenemedi, bu nedenle izinler buradan değiştirilemez. ({reason})",
"Star": "Yıldız ekle",
"Star / unstar": "Yıldız ekle / kaldır",
"Starred": "Yıldızlı",
@@ -1208,7 +1201,6 @@ export const catalog: Catalog = {
"Templates": "Şablonlar",
"Tenant": "Kiracı (Tenant)",
"Tenants": "Kiracılar (Tenants)",
"Tenants are a Stalwart Enterprise feature.": "Kiracılar bir Stalwart Enterprise özelliğidir.",
"Tentative": "Belirsiz / Taslak",
"Test notification": "Test bildirimi",
"Text color": "Metin rengi",
@@ -1286,14 +1278,12 @@ export const catalog: Catalog = {
"This account does not have the JMAP file storage capability.": "Bu hesap JMAP dosya depolama yeteneğine sahip değil.",
"This account has no password. It may sign in through a directory or single sign-on.": "Bu hesabın şifresi yok. Bir dizin veya tek oturum açma (SSO) yoluyla giriş yapıyor olabilir.",
"This account has permissions yours doesn't, so you can view it but not change it.": "Bu hesap sizin sahip olmadığınız izinlere sahiptir; bu nedenle görüntüleyebilirsiniz ancak değiştiremezsiniz.",
"This account has permissions yours doesn't.": "Bu hesap sizin sahip olmadığınız izinlere sahiptir.",
"This account has two-factor authentication on. {app} can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Bu hesapta iki adımlı doğrulama etkindir. {app} henüz bir kodla giriş yapmanızı desteklemiyor, bu nedenle başka bir cihazda oturum açmak bir uygulama şifresi gerektirir — veya iki adımlı doğrulamayı buradan kapatabilirsiniz.",
"This account no longer exists. Someone may have deleted it.": "Bu hesap artık mevcut değil. Biri silmiş olabilir.",
"This account signs in through an external directory, so its password can't be set here.": "Bu hesap harici bir dizin üzerinden giriş yapar, bu nedenle şifresi buradan ayarlanamaz.",
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make {app} the default from your operating system if you install it as an app.": "Bu tarayıcı {scheme} bağlantıları için uygulama kaydedemiyor. Özellikle Safari'de böyle bir API yoktur — bir uygulama olarak yüklerseniz {app} uygulamasını işletim sisteminizden yine de varsayılan yapabilirsiniz.",
"This cannot be undone.": "Bu işlem geri alınamaz.",
"This date cannot be changed on its own": "Bu tarih tek başına değiştirilemez",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Bu işlem bu hesaptaki e-postaları, takvimleri, kişileri ve dosyaları siler. Sunucu bunları arka planda kaldırır ve bu işlem geri alınamaz.",
"This domain is disabled on the server.": "Bu alan adı sunucuda devre dışı bırakılmış.",
"This domain no longer exists. Someone may have removed it.": "Bu alan adı artık mevcut değil. Başka biri kaldırmış olabilir.",
"This file is not UTF-8 text, so editing it here would corrupt it — download it instead.": "Bu dosya UTF-8 metni değil, bu nedenle burada düzenlemek dosyayı bozabilir — bunun yerine dosyayı indirin.",
@@ -1416,12 +1406,10 @@ export const catalog: Catalog = {
"You": "Siz",
"You can't change your own group memberships.": "Kendi grup üyeliklerinizi değiştiremezsiniz.",
"You can't change your own role.": "Kendi rolünüzü değiştiremezsiniz.",
"You can't delete the account you're signed in with.": "Giriş yaptığınız hesabı silemezsiniz.",
"You can't give a role permissions your own role doesn't have.": "Bir role kendi rolünüzün sahip olmadığı izinleri veremezsiniz.",
"You can't give an account permissions your own role doesn't have.": "Bir hesaba kendi rolünüzün sahip olmadığı izinleri veremezsiniz.",
"You can't move your own account into a tenant.": "Kendi hesabınızı bir kiracıya taşıyamazsınız.",
"You're all caught up": "Her şeyi yakaladınız",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "{user} olarak giriş yaptınız. Şifreniz asla tarayıcıda saklanmaz; sunucu Stalwart ile iletişim kurmak için her oturumda şifrelenmiş olarak saklar.",
"Your Sieve script has changes that have not been saved.": "Sieve betiğinizde kaydedilmemiş değişiklikler var.",
"Your active Sieve script was written by hand, so rules can't be added automatically. Open {where} to edit the script or switch to managed rules.": "Etkin Sieve betiğiniz el ile yazılmıştır, bu nedenle kurallar otomatik olarak eklenemez. Betiği düzenlemek veya yönetilen kurallara geçmek için {where} bölümünü açın.",
"Your active script “{name}” was written by hand.": "Aktif betiğiniz olan “{name}” el ile yazılmış.",
@@ -1504,7 +1492,6 @@ export const catalog: Catalog = {
"https://": "https://",
"https://meet.example.com/…": "https://meet.example.com/…",
"https://…": "https://…",
"ihasmail.org": "ihasmail.org",
"is": "şudur",
"is larger than": "şundan büyüktür",
"is not": "şu değildir",
@@ -1556,7 +1543,6 @@ export const catalog: Catalog = {
"year(s)": "yıl",
"{app} test": "{app} testi",
"{app} will tell you if a later message from this address is signed by anybody else.": "Bu adresten gelen sonraki bir ileti başka biri tarafından imzalanırsa {app} sizi bilgilendirecektir.",
"{app}'s own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "{app}'in kendi sürümü, derlendiği commit'in tarihi ve ardından bu commit'in nereden geldiğidir: {example}, 30 Ağustos 2026 tarihli ve 129 numaralı çekme isteğiyle gelen bir commit'ten derlenmiştir. Bir çekme isteğiyle gelmeyen commit bunun yerine kısa SHA değerini taşır — {sha}. Sürüm bilgisi kasıtlı olarak Stalwart hakkında hiçbir şey söylemez; bu derlemenin sunucudan gereksinim duyduğu şey yukarıdaki satırdır.",
"{count} conversations moved to {folder}": "{count} yazışma {folder} klasörüne taşındı",
"{count} folders": "{count} klasör",
"{email} is now your default identity": "{email} artık varsayılan kimliğiniz",
@@ -1587,6 +1573,93 @@ export const catalog: Catalog = {
"“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.": "“{name}” devre dışı bırakılacak (silinmeyecek) ve yeni bir “ihasmail” betiği görevi devralacaktır.",
"•••": "•••",
"+ New folder…": "+ Yeni klasör…",
"You no longer have access to {name}. Back to your own mail.": "{name} hesabına artık erişiminiz yok. Kendi postanıza dönün.",
"Send or close the message you're writing first.": "Önce yazdığınız iletiyi gönderin veya kapatın.",
"Locked account": "Kilitli hesap",
"Mail to show": "Gösterilecek posta",
"My mail": "Postam",
"Read only": "Salt okunur",
"Read and organize": "Okuma ve düzenleme",
"Full access": "Tam erişim",
"Locked account:": "Kilitli hesap:",
"You can send as this account": "Bu hesap adına gönderebilirsiniz",
"Back to my mail": "Postama dön",
"You'll sign in on your mail server's own page.": "Posta sunucunuzun kendi sayfasında oturum açacaksınız.",
"You'll enter your password on your mail server's sign-in page.": "Şifrenizi posta sunucunuzun oturum açma sayfasına gireceksiniz.",
"This sign-in didn't start in this browser. Try again.": "Bu oturum açma işlemi bu tarayıcıda başlamadı. Yeniden deneyin.",
"The sign-in took too long. Try again.": "Oturum açma çok uzun sürdü. Yeniden deneyin.",
"Sign-in was cancelled.": "Oturum açma iptal edildi.",
"The mail server didn't accept the sign-in. Try again.": "Posta sunucusu oturum açmayı kabul etmedi. Yeniden deneyin.",
"That account is on a different mail server than the address you entered. Sign in with that address.": "Bu hesap, girdiğiniz adresten farklı bir posta sunucusunda. O adresle oturum açın.",
"This mail server isn't supported.": "Bu posta sunucusu desteklenmiyor.",
"Couldn't reach the mail server. Try again in a moment.": "Posta sunucusuna ulaşılamadı. Biraz sonra yeniden deneyin.",
"Your password was changed. Sign in with the new one.": "Şifreniz değiştirildi. Yeni şifrenizle oturum açın.",
"Your sign-in ended. Sign in again.": "Oturumunuz sona erdi. Yeniden oturum açın.",
"{host} does not resolve": "{host} çözümlenemiyor",
"unavailable": "kullanılamıyor",
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} eksiksiz bir posta paketidir: bir posta sunucusu, onu yöneten konsol ve bu web posta; her biri kendi programıdır, birlikte kurulur ve AGPL kapsamında ücretsizdir.",
"Built on {project}": "{project} üzerine kurulu",
"The suite": "Paket",
"Mail server": "Posta sunucusu",
"Administration console": "Yönetim konsolu",
"Webmail": "Web posta",
"Webmail node": "Web posta düğümü",
"Mail server node": "Posta sunucusu düğümü",
"This webmail works with the inbuxa mail server, and sign-in refuses a server that doesn't offer what it needs.": "Bu web posta inbuxa posta sunucusuyla çalışır ve gereken özellikleri sunmayan bir sunucuda oturum açmayı reddeder.",
"{app}'s own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "{app}'in kendi sürümü, derlendiği commit'in tarihi ve ardından bu commit'in nereden geldiğidir: {example}, 30 Ağustos 2026 tarihli ve 129 numaralı çekme isteğiyle gelen bir commit'ten derlenmiştir. Bir çekme isteğiyle gelmeyen commit bunun yerine kısa SHA değerini taşır — {sha}. Sürüm bilgisi kasıtlı olarak posta sunucusu hakkında hiçbir şey söylemez; bu derlemenin sunucudan gereksinim duyduğu şey yukarıdaki satırdır.",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "{user} olarak giriş yaptınız. Bu web posta şifrenizi hiç görmez: posta sunucunuzdan alınan ve her oturumda şifrelenen bir oturum anahtarı tutar.",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to the mail server.": "{user} olarak giriş yaptınız. Şifreniz asla tarayıcıda saklanmaz; sunucu, posta sunucusuyla iletişim kurmak için onu her oturumda şifrelenmiş olarak saklar.",
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Kuruluşunuz yalnızca {app} ve JMAP uygulamalarına izin veriyor; bu nedenle telefon ve masaüstü posta uygulamaları bu hesaba bağlanamaz.",
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Kuruluşunuz posta uygulamaları için {protocols} protokollerini kapattı. Bunları kullanan posta uygulamaları bu hesaba bağlanamaz; diğerleri bağlanmaya devam eder.",
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Şifrenizi değiştirmek, buradaki dahil her yerdeki oturumunuzu kapatır ve yeni şifrenizle yeniden oturum açarsınız. Uygulama şifreleri çalışmaya devam eder.",
"One of several signals the spam filter weighed": "İstenmeyen posta filtresinin değerlendirdiği birkaç işaretten biri",
"Language model's opinion": "Dil modelinin görüşü",
"This account was handed to you to read. Nothing in it can be changed.": "Bu hesap size okumanız için verildi. İçindeki hiçbir şey değiştirilemez.",
"You can file and move mail in this account, but not delete it.": "Bu hesaptaki postaları dosyalayıp taşıyabilirsiniz, ancak silemezsiniz.",
"Send anyway?": "Yine de gönderilsin mi?",
"Your reason is recorded with the message.": "Gerekçeniz iletiyle birlikte kaydedilir.",
"Why this needs to go": "Bunun neden gönderilmesi gerekiyor",
"Not sent yet:": "Henüz gönderilmedi:",
"Not sent:": "Gönderilmedi:",
"Send anyway…": "Yine de gönder…",
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Silme, kilitleme ve yasal saklama işlemleri yönetim konsolunda yapılır; konsol gerekçesini kaydeder ve saklamanın kapsadığı her şeyi korur.",
"Open in the console": "Konsolda aç",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Kuruluşunuz için eski posta protokolleri kapalı. Yalnızca {app} ve JMAP uygulamaları oturum açabilir.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Kuruluşunuz için bazı eski posta protokolleri kapalı: {protocols}.",
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Ayrıntılı metrikler, teslimat kuyruğu, günlükler ve sunucu ayarları inbuxa Admin'dedir.",
"Open inbuxa Admin": "inbuxa Admin'i aç",
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Posta sunucusu bu rolü varsayılan olarak {kinds} grubuna verir. Buradaki bir değişiklik bu şekilde sahip olan herkese ulaşır.",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Posta sunucusu bu rolü varsayılan olarak verir, bu nedenle silinemez. Önce inbuxa Admin'de varsayılanları değiştirin.",
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Posta sunucusunun izin listesi yüklenemedi, bu nedenle izinler buradan değiştirilemez. ({reason})",
"Legacy mail apps": "Eski posta uygulamaları",
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "{tenant} için kapalı. Alan adlarında yalnızca {app} ve JMAP uygulamaları oturum açabilir.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "{tenant} için bazıları kapalı: {protocols}. Bunları yönetim konsolundan tek tek değiştirin.",
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "{tenant} için açık. Posta uygulamaları alan adlarında IMAP, POP3 ve ManageSieve kullanabilir.",
"Turn legacy protocols back on": "Eski protokolleri yeniden aç",
"Turn off legacy protocols…": "Eski protokolleri kapat…",
"No account used a legacy mail app in the last 30 days.": "Son 30 günde hiçbir hesap eski bir posta uygulaması kullanmadı.",
"Their mail apps will stop working the moment you turn this on:": "Bunu açtığınız anda posta uygulamaları çalışmayı bırakır:",
"Only {app} and JMAP apps will work.": "Yalnızca {app} ve JMAP uygulamaları çalışır.",
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "Eski posta protokolleri (IMAP, POP3, ManageSieve ve posta uygulamalarından gönderme) {tenant} içindeki herkes için kapatılacak.",
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "Telefon ve masaüstü posta uygulamaları posta alıp göndermeyi bırakacak. Buna iPhone ve iPad Mail, Gmail ve Outlook uygulamaları, Outlook, Thunderbird ve Apple Mail dahildir. Kullanıcılar bu uygulamalarda oturum açma hataları görecek.",
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "Bir posta uygulamasından yönetilen filtreler (ManageSieve) çalışmayı bırakacak. {app} içinde ayarlanan filtreler çalışmaya devam eder.",
"Incoming mail is not affected. Calendars and contacts are not affected.": "Gelen posta etkilenmez. Takvimler ve kişiler etkilenmez.",
"People keep full access through {app}, which can be installed as an app on phones and computers.": "Kullanıcılar, telefonlara ve bilgisayarlara uygulama olarak kurulabilen {app} üzerinden tam erişimini korur.",
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "Posta uygulamalarından gönderme (SMTP gönderimi) çalışmayı bırakacak, ancak bağlantı noktaları açık kalır: posta uygulamalarına oturum açamayacakları bildirilir. Gelen posta (SMTP) ve {app} (JMAP) etkilenmez ve buradan kapatılamaz.",
"You can turn legacy protocols back on at any time.": "Eski protokolleri istediğiniz zaman yeniden açabilirsiniz.",
"To confirm, type {phrase}": "Onaylamak için {phrase} yazın",
"Turn off legacy protocols": "Eski protokolleri kapat",
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Bir görselin https adresi veya data URL'si. Posta sunucusu logo gösterdiği yerlerde bunu kiracının kullanıcılarına gösterir.",
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "Posta sunucusu bir limitin izin verdiğinden fazlasını oluşturmayı reddeder. Boş bir alan limit olmadığı anlamına gelir.",
"This message wasn't sent under the server's rules": "Bu ileti sunucunun kuralları gereği gönderilmedi",
"You can't send from {name}. It was handed to you to read, not to send as.": "{name} adına gönderemezsiniz. Bu hesap size gönderim için değil, okumanız için verildi.",
"Held for review: it's sent once a reviewer releases it": "İnceleme için bekletiliyor: bir inceleyici onayladığında gönderilecek",
"Not sent yet: check the warning": "Henüz gönderilmedi: uyarıyı inceleyin",
"Not sent: blocked by the server's rules": "Gönderilmedi: sunucunun kuralları tarafından engellendi",
"IMAP": "IMAP",
"POP3": "POP3",
"ManageSieve": "ManageSieve",
"SMTP": "SMTP",
},
plurals: {
"({n} deleted first)": {
@@ -1841,5 +1914,9 @@ export const catalog: Catalog = {
one: "{rule}, {n} kez",
other: "{rule}, {n} kez",
},
"{n} accounts used a legacy mail app in the last 30 days.": {
one: "Son 30 günde {n} hesap eski bir posta uygulaması kullandı.",
other: "Son 30 günde {n} hesap eski bir posta uygulaması kullandı.",
},
},
};
+11
View File
@@ -639,6 +639,12 @@ export const catalog: Catalog = {
"+{n} more": "+{n}",
"Contacts": "Контакти",
"Collected": "Зібрані",
"Save people you write to as contacts": "Зберігати адресатів як контакти",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Усі, кому ви пишете і кого ще немає в контактах, додаються до адресної книги «Зібрані» й пропонуються на всіх пристроях. Ваші власні адреси не додаються.",
"Contacts are not available": "Контакти недоступні",
"New contact": "Новий контакт",
"Edit contact": "Змінити контакт",
@@ -724,6 +730,8 @@ export const catalog: Catalog = {
// ── Settings ───────────────────────────────────────────────────────
"Settings": "Налаштування",
"Show folder list": "Показати список тек",
"Hide folder list": "Сховати список тек",
"All settings": "Усі налаштування",
"Sections": "Розділи",
"General": "Загальні",
@@ -1324,6 +1332,9 @@ export const catalog: Catalog = {
"Send anyway": "Усе одно надіслати",
"Send canceled — the message is back in Drafts": "Надсилання скасовано — лист повернувся до чернеток",
"Send failed: {error}": "Не вдалося надіслати: {error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не вдалося перевірити, чи цей лист уже надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не вдалося підтвердити, чи цей лист надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
"This message had already been sent, so it wasn't sent again.": "Цей лист уже було надіслано, тому повторно його не надсилали.",
"Send invites": "Надіслати запрошення",
"Send scheduled for {when}": "Надсилання заплановано на {when}",
"Send without a subject?": "Надіслати без теми?",
+11
View File
@@ -641,6 +641,12 @@ export const catalog: Catalog = {
"+{n} more": "还有 {n} 项",
"Contacts": "联系人",
"Collected": "已收集",
"Save people you write to as contacts": "将您发信的联系人保存为联系人",
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "您发信的对象如果还不是联系人,会被添加到“已收集”通讯录,在所有设备上都会作为建议显示。您自己的地址不会被添加。",
"Contacts are not available": "联系人不可用",
"New contact": "新建联系人",
"Edit contact": "编辑联系人",
@@ -726,6 +732,8 @@ export const catalog: Catalog = {
// ── Settings ───────────────────────────────────────────────────────
"Settings": "设置",
"Show folder list": "显示文件夹列表",
"Hide folder list": "隐藏文件夹列表",
"All settings": "全部设置",
"Sections": "分区",
"General": "常规",
@@ -1335,6 +1343,9 @@ export const catalog: Catalog = {
"Send anyway": "仍然发送",
"Send canceled — the message is back in Drafts": "已取消发送——邮件已回到草稿箱",
"Send failed: {error}": "发送失败:{error}",
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "无法检查这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "无法确认这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
"This message had already been sent, so it wasn't sent again.": "这封邮件已经发送过,因此没有再次发送。",
"Send invites": "发送邀请",
"Send scheduled for {when}": "已定时于 {when} 发送",
"Send without a subject?": "不填主题就发送吗?",
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSession } from "@/store/session";
/**
* inbuxa MA-B: the accounts signed in in this browser. The server lists them
* and swaps the one in front; the web app clears what it cached for the
* previous account and reloads, and signing out of one reloads into the next.
*/
let reload: ReturnType<typeof vi.fn>;
let calls: { url: string; method: string }[];
let answers: Record<string, unknown>;
beforeEach(() => {
reload = vi.fn();
Object.defineProperty(window, "location", { configurable: true, value: { ...window.location, reload } });
calls = [];
answers = {};
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const path = String(url);
calls.push({ url: path, method: init?.method ?? "GET" });
const key = Object.keys(answers).find((k) => path.endsWith(k));
const body = key ? answers[key] : { ok: true };
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response;
}),
);
localStorage.setItem("ihasmail:cached-thing", "from the account in front");
useSession.setState({ status: "authenticated", signedIn: [], canAddAccount: false });
});
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
});
describe("account switcher", () => {
it("lists the accounts and whether one more may be added", async () => {
answers["/api/auth/accounts"] = {
accounts: [
{ id: "a", username: "[email protected]", front: true },
{ id: "b", username: "[email protected]", front: false },
],
canAdd: true,
};
await useSession.getState().loadSignedIn();
expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["[email protected]", "[email protected]"]);
expect(useSession.getState().canAddAccount).toBe(true);
});
it("switching asks the server, forgets the cache and reloads", async () => {
await useSession.getState().switchTo("b");
expect(calls.some((c) => c.url.endsWith("/api/auth/accounts/b/front") && c.method === "POST")).toBe(true);
expect(localStorage.getItem("ihasmail:cached-thing")).toBeNull();
expect(reload).toHaveBeenCalledOnce();
});
it("signing out of one reloads into the next when there is one", async () => {
answers["/api/auth/logout"] = { ok: true, next: true };
await useSession.getState().logout();
expect(reload).toHaveBeenCalledOnce();
});
it("signing out of the last one, or of all, ends at the sign-in page", async () => {
answers["/api/auth/logout-all"] = { ok: true };
await useSession.getState().logoutAll();
expect(calls.some((c) => c.url.endsWith("/api/auth/logout-all"))).toBe(true);
expect(reload).not.toHaveBeenCalled();
expect(useSession.getState().status).toBe("anonymous");
// The next ordinary sign-out goes back to signing out of one
useSession.setState({ status: "authenticated" });
answers["/api/auth/logout"] = { ok: true };
await useSession.getState().logout();
expect(calls.filter((c) => c.url.endsWith("/api/auth/logout")).length).toBe(1);
});
});
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useCompose } from "@/store/compose";
import { useContacts } from "@/store/contacts";
import { useMail } from "@/store/mail";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import type { JmapSession } from "@/jmap/types";
/* A confirmed send hands its recipients to the contacts store, unless the reader turned that off. */
function okServer() {
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "Email/set" && args.create) return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: "e1" } } }, id];
if (name === "EmailSubmission/set") return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: "s1" } } }, id];
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
}));
}
let collect: ReturnType<typeof vi.fn>;
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: {}, [CAP.mail]: {}, [CAP.submission]: {} },
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({
accountId: "a1",
identities: [{ id: "i1", name: "Me", email: "[email protected]", replyTo: null }] as never,
mailboxes: { mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" } } as never,
});
collect = vi.fn(async () => 1);
useContacts.setState({ collectRecipients: collect as never });
okServer();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const send = async () => {
const key = useCompose.getState().open({ to: [{ name: "Ann", email: "[email protected]" }], cc: [{ name: null, email: "[email protected]" }], subject: "Hi" });
await useCompose.getState().send(key);
};
describe("collecting recipients when a message is sent", () => {
it("passes every recipient, and the sender's own addresses to leave out", async () => {
await send();
expect(collect).toHaveBeenCalledTimes(1);
const [addrs, own] = collect.mock.calls[0]!;
expect((addrs as { email: string }[]).map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
expect(own).toEqual(["[email protected]"]);
});
it("does not when the setting is off", async () => {
useSettings.setState((s) => ({ settings: { ...s.settings, collectRecipients: false } }));
await send();
expect(collect).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useContacts } from "@/store/contacts";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import type { ContactCard, JmapSession } from "@/jmap/types";
/*
* People written to used to be remembered only in this browser, as a list of
* recent addresses, so a new device suggested nobody. Each confirmed send now
* saves the recipients who are not contacts yet, in an address book of their
* own, on the server.
*/
interface Calls {
books: Record<string, unknown>[];
cards: Record<string, unknown>[];
}
function server(): Calls {
const calls: Calls = { books: [], cards: [] };
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "AddressBook/set" && args.create) {
calls.books.push(args.create as Record<string, unknown>);
useContacts.setState((s) => ({ books: { ...s.books, bNew: { id: "bNew", name: "Collected" } as never } }));
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { b: { id: "bNew" } } }, id];
}
if (name === "ContactCard/set" && args.create) {
const create = args.create as Record<string, unknown>;
calls.cards.push(...Object.values(create) as Record<string, unknown>[]);
return [name, { accountId: "a1", oldState: "1", newState: "2", created: Object.fromEntries(Object.keys(create).map((k, i) => [k, { id: `n${i}` }])) }, id];
}
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false, changed: [], created: [], updated: [], destroyed: [], hasMoreChanges: false, oldState: "1", newState: "1" }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
}));
return calls;
}
const card = (id: string, email: string): ContactCard =>
({ id, uid: id, addressBookIds: { b1: true }, name: { full: id }, emails: { e: { address: email } } }) as unknown as ContactCard;
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInSet: 500 }, [CAP.contacts]: {} },
accounts: { a1: { accountCapabilities: { [CAP.contacts]: {} } } },
primaryAccounts: { [CAP.contacts]: "a1" },
state: "s1",
} as unknown as JmapSession;
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
useContacts.setState({
accountId: "a1",
available: true,
loaded: true,
books: { b1: { id: "b1", name: "Personal" } as never },
cards: { c1: card("c1", "[email protected]") },
sharedCards: {},
syncCards: (async () => undefined) as never,
loadBooks: (async () => undefined) as never,
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("saving the people written to", () => {
it("adds only addresses that are not contacts yet, and never your own", async () => {
const calls = server();
const n = await useContacts.getState().collectRecipients(
[
{ name: "Known Person", email: "[email protected]" },
{ name: "Smith, Jane", email: "[email protected]" },
{ name: null, email: "[email protected]" },
{ name: "Jane again", email: "[email protected]" },
],
["[email protected]"],
);
expect(n).toBe(1);
expect(calls.cards).toHaveLength(1);
const added = calls.cards[0]!;
expect(Object.values(added.emails as Record<string, { address: string }>)[0]!.address).toBe("[email protected]");
// Filed under the Collected book, with the name split the way the editor does.
expect(added.addressBookIds).toEqual({ bNew: true });
expect((added.name as { components: { kind: string; value: string }[] }).components.map((c) => c.value)).toEqual(expect.arrayContaining(["Jane", "Smith"]));
});
it("creates the Collected book once and remembers it", async () => {
const calls = server();
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
expect(calls.books).toHaveLength(1);
expect(useSettings.getState().settings.collectedBookId).toBe("bNew");
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
expect(calls.books).toHaveLength(1);
expect(calls.cards.at(-1)!.addressBookIds).toEqual({ bNew: true });
});
it("makes a new book when the remembered one has been deleted", async () => {
const calls = server();
useSettings.setState((s) => ({ settings: { ...s.settings, collectedBookId: "gone" } }));
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
expect(calls.books).toHaveLength(1);
});
it("does nothing, and makes no book, when everyone is already a contact", async () => {
const calls = server();
const n = await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
expect(n).toBe(0);
expect(calls.books).toHaveLength(0);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@@ -70,7 +70,7 @@ afterEach(() => {
describe("delegation", () => {
it("is read only from the session's mark, never from a shared account's capabilities", () => {
const session = sessionWith(delegated("organize", true));
expect(delegationOf(session, "locked")).toEqual({ locked: true, access: "organize", sendAs: true, until: null });
expect(delegationOf(session, "locked")).toEqual({ locked: true, kind: "lock", access: "organize", sendAs: true, until: null });
expect(delegationOf(session, "shared")).toBeNull();
expect(delegationOf(session, "own")).toBeNull();
expect(delegatedAccounts(session).map((a) => a.id)).toEqual(["locked"]);
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useContacts } from "@/store/contacts";
import type { ContactCard } from "@/jmap/types";
/*
* What the recipient field offers as you type. Matching used to take the
* typed text as one piece, so "jane smi" found nobody filed as "Smith, Jane",
* and a nickname or a company name found nothing at all.
*/
const card = (id: string, full: string, email: string, extra: Partial<ContactCard> = {}): ContactCard =>
({ id, uid: id, addressBookIds: { b1: true }, name: { full }, emails: { e: { address: email } }, ...extra }) as unknown as ContactCard;
beforeEach(() => {
useContacts.setState({
accountId: "a1",
available: false,
loaded: true,
loading: false,
principalsLoaded: true,
principals: [],
sharedCards: {},
recent: [],
cards: {
c1: card("c1", "Smith, Jane", "[email protected]"),
c2: card("c2", "Robert Jones", "[email protected]", { nicknames: { n: { name: "Bobby" } } } as Partial<ContactCard>),
c3: card("c3", "Ann Lee", "[email protected]", { organizations: { o: { name: "Globex Industries" } } } as Partial<ContactCard>),
c4: card("c4", "Ann Taylor", "[email protected]"),
},
});
});
const emails = async (q: string) => (await useContacts.getState().suggest(q)).map((s) => s.email);
describe("recipient suggestions", () => {
it("matches the words typed in any order", async () => {
expect(await emails("jane smi")).toEqual(["[email protected]"]);
expect(await emails("smi jan")).toEqual(["[email protected]"]);
});
it("does not match when one of the words fits nobody", async () => {
expect(await emails("jane xyz")).toEqual([]);
});
it("matches a nickname and an organization", async () => {
expect(await emails("bobby")).toEqual(["[email protected]"]);
expect(await emails("globex")).toEqual(["[email protected]"]);
});
it("puts someone written to lately ahead of an equal match", async () => {
expect(await emails("ann")).toEqual(["[email protected]", "[email protected]"]);
useContacts.setState({ recent: [{ name: "Ann Taylor", email: "[email protected]" }] });
expect((await emails("ann"))[0]).toBe("[email protected]");
});
it("still ranks a match at the start above a word inside the name", async () => {
// "jo" starts Jones's surname word; it starts no other card's name or address.
expect(await emails("jo")).toEqual(["[email protected]"]);
});
});
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, CAP, client } from "@/jmap/client";
import { sendOutcomeUnknown, useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import { useToasts } from "@/ui/toast";
import type { JmapSession } from "@/jmap/types";
/*
* A send whose reply goes missing may still have gone out. "Send failed ->
* Open draft -> Send" then delivered it twice: nothing said which message
* the first attempt was, so nothing could check. Each send now carries its
* own Message-ID, and an unanswered send asks the server what happened to it
* before calling it failed.
*/
type Mode = "ok" | "lost-reply" | "never-arrived" | "orphan" | "gateway" | "refused" | "offline-after";
interface Server {
emails: { id: string; messageId: string }[];
submissions: { id: string; emailId: string }[];
destroyed: string[];
creates: number;
}
function server(mode: Mode): Server {
const st: Server = { emails: [], submissions: [], destroyed: [], creates: 0 };
let first = true;
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const sending = body.methodCalls.some(([n]) => n === "EmailSubmission/set");
if (sending && first && mode !== "ok") {
first = false;
if (mode === "never-arrived") throw new TypeError("Failed to fetch");
if (mode === "gateway") return { ok: false, status: 502, statusText: "Bad Gateway", json: async () => ({ error: "bad_gateway" }) } as Response;
if (mode === "refused") return { ok: false, status: 400, statusText: "Bad Request", json: async () => ({ type: "urn:ietf:params:jmap:error:notRequest" }) } as Response;
// The server does the work; only the answer is lost.
const [, args] = body.methodCalls.find(([n, a]) => n === "Email/set" && (a as { create?: unknown }).create)!;
const m = (args.create as { m: { messageId: string[] } }).m;
const id = `e${st.emails.length + 1}`;
st.creates++;
st.emails.push({ id, messageId: m.messageId[0]! });
if (mode !== "orphan") st.submissions.push({ id: `s${id}`, emailId: id });
throw new TypeError("Failed to fetch");
}
if (mode === "offline-after" && !first) throw new TypeError("Failed to fetch");
if (mode === "offline-after" && sending) {
first = false;
throw new TypeError("Failed to fetch");
}
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "Email/set" && (args as { create?: unknown }).create) {
const m = (args.create as { m: { messageId: string[] } }).m;
const eid = `e${st.emails.length + 1}`;
st.creates++;
st.emails.push({ id: eid, messageId: m.messageId[0]! });
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: eid } } }, id];
}
if (name === "EmailSubmission/set") {
const eid = st.emails[st.emails.length - 1]!.id;
st.submissions.push({ id: `s${eid}`, emailId: eid });
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: `s${eid}` } } }, id];
}
if (name === "Email/set" && (args as { destroy?: string[] }).destroy) {
st.destroyed.push(...((args as { destroy: string[] }).destroy));
return [name, { accountId: "a1", oldState: "1", newState: "2", destroyed: (args as { destroy: string[] }).destroy }, id];
}
if (name === "Email/query") {
const [, value] = ((args.filter ?? {}) as { header?: [string, string] }).header ?? [];
const ids = value ? st.emails.filter((e) => e.messageId === value && !st.destroyed.includes(e.id)).map((e) => e.id) : [];
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
}
if (name === "EmailSubmission/query") {
const want = ((args.filter ?? {}) as { emailIds?: string[] }).emailIds ?? [];
const ids = st.submissions.filter((s) => want.includes(s.emailId)).map((s) => s.id);
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
}
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
}));
return st;
}
const toastTexts = () => useToasts.getState().toasts.map((t) => t.message);
async function sendOne(init: Record<string, unknown> = {}) {
const key = useCompose.getState().open({ to: [{ name: null, email: "[email protected]" }], subject: "Hi", ...init });
await useCompose.getState().send(key);
return key;
}
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {}, [CAP.submission]: {} },
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({
accountId: "a1",
identities: [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as never,
mailboxes: {
mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" },
mbDrafts: { id: "mbDrafts", role: "drafts", parentId: null, name: "Drafts" },
} as never,
});
useToasts.setState({ toasts: [] });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("which failures leave the outcome open", () => {
it("treats a refusal as not sent, and no answer or a gateway error as unknown", () => {
expect(sendOutcomeUnknown(new ApiError(400, "bad"))).toBe(false);
expect(sendOutcomeUnknown(new ApiError(401, "unauthenticated"))).toBe(false);
expect(sendOutcomeUnknown(new ApiError(502, "bad_gateway"))).toBe(true);
expect(sendOutcomeUnknown(new ApiError(408, "timeout"))).toBe(true);
expect(sendOutcomeUnknown(new TypeError("Failed to fetch"))).toBe(true);
});
});
describe("sending once, whatever goes wrong on the way back", () => {
it("gives every send a Message-ID on the sender's domain", async () => {
const st = server("ok");
await sendOne();
expect(st.emails).toHaveLength(1);
expect(st.emails[0]!.messageId).toMatch(/^[0-9a-f-]{36}@example\.org$/);
expect(toastTexts()).toContain("Message sent");
});
it("calls it sent when the reply was lost but the server sent it", async () => {
const st = server("lost-reply");
await sendOne();
expect(st.submissions).toHaveLength(1);
expect(st.destroyed).toEqual([]);
expect(toastTexts()).toContain("Message sent");
expect(useCompose.getState().drafts).toHaveLength(0);
});
it("removes a message that was created but never submitted, and says it failed", async () => {
const st = server("orphan");
await sendOne();
expect(st.destroyed).toEqual(["e1"]);
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
});
it("says it failed when the request never reached the server", async () => {
const st = server("never-arrived");
await sendOne();
expect(st.emails).toHaveLength(0);
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
});
it("asks the server after a gateway error too, rather than assuming", async () => {
const st = server("gateway");
await sendOne();
expect(st.creates).toBe(0);
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
});
it("does not ask after a refusal: the server did not run it", async () => {
server("refused");
const fetchMock = vi.mocked(fetch);
await sendOne();
const asked = fetchMock.mock.calls.some(([, init]) => (init as RequestInit).body?.toString().includes("Email/query"));
expect(asked).toBe(false);
});
it("says plainly when it cannot tell, instead of offering a resend that could duplicate", async () => {
server("offline-after");
await sendOne();
const msgs = toastTexts();
expect(msgs.some((t) => t.includes("Couldn't confirm whether this message was sent"))).toBe(true);
expect(msgs.some((t) => t.startsWith("Send failed"))).toBe(false);
});
});
describe("sending a draft again after a failure", () => {
it("keeps the Message-ID, and sends nothing when the first attempt went out", async () => {
const st = server("ok");
// The first attempt went out; the client never heard.
st.emails.push({ id: "e1", messageId: "[email protected]" });
st.submissions.push({ id: "se1", emailId: "e1" });
await sendOne({ sendMessageId: "[email protected]" });
expect(st.creates).toBe(0);
expect(toastTexts().some((t) => t.includes("had already been sent"))).toBe(true);
});
it("sends it, under the same Message-ID, when the first attempt did not go out", async () => {
const st = server("ok");
await sendOne({ sendMessageId: "[email protected]" });
expect(st.creates).toBe(1);
expect(st.emails[0]!.messageId).toBe("[email protected]");
expect(toastTexts()).toContain("Message sent");
});
it("reopens a failed draft with its Message-ID, so the next send can check", async () => {
server("orphan");
await sendOne();
const toast = useToasts.getState().toasts.find((t) => t.message.startsWith("Send failed"))!;
toast.action!.onClick();
const reopened = useCompose.getState().drafts[0]!;
expect(reopened.sendMessageId).toMatch(/@example\.org$/);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { useSession, viewingDelegation } from "@/store/session";
import { findSharedMail, sharedMailCandidates } from "@/lib/sharedMail";
import { delegatedAccounts } from "@/lib/delegation";
/**
* MA-A: a group's mailbox, or folders someone shared, can be opened in place
* of the reader's own mail. Only accounts that answer with a mailbox are
* offered, only mail follows the switch, and losing the account takes the
* reader back.
*/
const sessionWith = (extra: Record<string, unknown> = {}) =>
({
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} },
accounts: {
own: {
name: "[email protected]",
isPersonal: true,
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
},
// A group: every capability, and mailboxes
group: {
name: "[email protected]",
isPersonal: false,
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
},
// Someone who shared one calendar: mail is advertised, but no mailbox answers
calendarOnly: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {} } },
// No mail at all
filesOnly: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.filenode]: {} } },
// A locked account handed over: listed by delegation.ts, never here
locked: {
name: "[email protected]",
isPersonal: false,
accountCapabilities: { [CAP.mail]: {}, "urn:inbuxa:jmap": { delegation: { locked: true, access: "read", sendAs: false, until: null } } },
},
// A shared mailbox an administrator assigned (MA-S): marked, so never asked about
desk: {
name: "[email protected]",
isPersonal: false,
accountCapabilities: {
[CAP.mail]: {},
[CAP.calendars]: {},
"urn:inbuxa:jmap": { delegation: { locked: true, kind: "sharedMailbox", access: "organize", sendAs: true, until: null } },
},
},
...extra,
},
primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" },
state: "s",
}) as unknown as JmapSession;
/** Accounts whose Mailbox/get answers with a mailbox. */
let withMailboxes = new Set(["group"]);
let nextSession: JmapSession;
beforeEach(() => {
withMailboxes = new Set(["group"]);
nextSession = sessionWith();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
if (String(url).includes("/api/auth/session")) {
return { ok: true, status: 200, json: async () => nextSession } as Response;
}
const { methodCalls } = JSON.parse((init?.body as string) ?? "{}") as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = methodCalls.map(([name, args, id]) => [
name,
{ accountId: args.accountId, state: "1", list: withMailboxes.has(String(args.accountId)) ? [{ id: "a" }] : [], notFound: [] },
id,
]);
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}),
);
const session = sessionWith();
client.session = session;
useSession.setState({ status: "authenticated", session, accountId: "own", viewing: null, sharedMail: [], delegationEnded: null });
});
afterEach(() => {
useSession.setState({ viewing: null, sharedMail: [] });
vi.unstubAllGlobals();
});
describe("shared mail", () => {
it("considers only other people's accounts that advertise mail, leaving locked accounts to delegation", () => {
expect(sharedMailCandidates(sessionWith()).map((a) => a.id)).toEqual(["calendarOnly", "desk", "group"]);
});
it("offers only accounts that answer with a mailbox", async () => {
// The shared mailbox answers no Mailbox/get here, and is offered anyway
expect((await findSharedMail(sessionWith())).map((a) => a.name)).toEqual(["[email protected]", "[email protected]"]);
});
it("can't be opened until it is found, and then shows mail only", async () => {
useSession.getState().view("group");
expect(useSession.getState().viewing).toBeNull();
await useSession.getState().loadSharedMail();
useSession.getState().view("group");
expect(useSession.getState().viewing).toBe("group");
// Calendars, contacts and files stay the reader's own
expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own");
expect(useSession.getState().viewAccountFor(CAP.contacts)).toBe("own");
expect(useSession.getState().viewAccountFor(CAP.filenode)).toBe("own");
// An account with no mailboxes is never offered
useSession.getState().view("calendarOnly");
expect(useSession.getState().viewing).toBe("group");
});
it("takes the reader back to their own mail when the account goes away", async () => {
await useSession.getState().loadSharedMail();
useSession.getState().view("group");
const session = sessionWith();
delete (session.accounts as Record<string, unknown>).group;
nextSession = session;
withMailboxes = new Set();
await useSession.getState().refresh();
expect(useSession.getState().viewing).toBeNull();
expect(useSession.getState().delegationEnded).toBe("[email protected]");
});
it("shows an assigned shared mailbox as shared, not locked, keeping its access level", async () => {
await useSession.getState().loadSharedMail();
expect(delegatedAccounts(useSession.getState().session).map((a) => a.id)).toEqual(["locked"]);
useSession.getState().view("desk");
expect(useSession.getState().viewing).toBe("desk");
expect(viewingDelegation()?.access).toBe("organize");
// Mail only, like any shared mailbox
expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own");
});
});
+120 -6
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { client, setErrorMessage } from "@/jmap/client";
import { ApiError, client, setErrorMessage } from "@/jmap/client";
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
import { formatFullDate, uid } from "@/lib/format";
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
@@ -102,6 +102,12 @@ export interface Draft {
dlp?: DlpRefusalInfo | null;
/** The reason to send despite a DLP warning, for the next send only. */
dlpOverride?: string | null;
/**
* The Message-ID this draft goes out under, fixed at its first send and
* kept if the draft comes back after a failure. Sending it again asks the
* server first whether a message with this id already went out.
*/
sendMessageId?: string;
}
export interface DlpRefusalInfo {
@@ -738,8 +744,12 @@ export const useCompose = create<ComposeState>((set, get) => ({
},
async send(key) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const found = get().drafts.find((x) => x.key === key);
if (!found) return;
// A draft that already has a Message-ID has been sent before, and failed.
const retry = Boolean(found.sendMessageId);
const fromEmail = (useMail.getState().identities.find((i) => i.id === found.identityId) ?? useMail.getState().identities[0])?.email ?? "";
const d: Draft = { ...found, sendMessageId: found.sendMessageId ?? newMessageId(fromEmail) };
const delay = settings().undoSendSeconds;
// A schedule the user left sitting until it passed is just a send now.
const scheduling = d.sendAt !== null && d.sendAt > Date.now();
@@ -754,7 +764,18 @@ export const useCompose = create<ComposeState>((set, get) => ({
return { pendingSends: rest };
});
try {
const sent = await sendInternal(d, get);
const sent = await sendInternal(d, get, { retry });
// Everyone written to who is not a contact yet, so they are suggested on every
// device. Also when a resend found the first attempt had gone out: its reply
// was what got lost, so nothing was collected then.
if (settings().collectRecipients) {
const own = useMail.getState().identities.map((i) => i.email);
void useContacts.getState().collectRecipients([...d.to, ...d.cc, ...d.bcc], own).catch(() => undefined);
}
if (sent.alreadySent) {
toast.success(translate("This message had already been sent, so it wasn't sent again."));
return;
}
toast.success(
sent.held
? translate("Held for review: it's sent once a reviewer releases it")
@@ -769,6 +790,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
toast.error(err.info.kind === "warning" ? translate("Not sent yet: check the warning") : translate("Not sent: blocked by the server's rules"));
return;
}
if (err instanceof SendOutcomeUnknown) {
toast.error(err.message, {
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: err.message }], activeKey: d.key })) },
duration: 30000,
});
return;
}
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), {
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
duration: 15000,
@@ -1082,9 +1110,54 @@ export function buildSubmission(opts: {
/** What the server said of a send: whether DLP held it for review (inbuxa). */
interface Sent {
held: boolean;
/** A resend found the first attempt had already gone out; nothing was sent again. */
alreadySent?: boolean;
}
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
/**
* A send whose outcome nobody can state: the request went out, no answer came
* back, and the server could not be asked afterwards either. Offering a plain
* "send again" here is how one message is delivered twice.
*/
export class SendOutcomeUnknown extends Error {}
/** A Message-ID for one send, on the sending identity's domain (RFC 5322 §3.6.4). */
export function newMessageId(fromEmail: string): string {
const domain = fromEmail.split("@")[1] || "localhost";
return `${crypto.randomUUID()}@${domain}`;
}
/**
* Whether a failed request may still have been carried out. A refusal (4xx)
* means the server did not run it; no answer, or a 5xx from the proxy
* that lost the upstream reply, means it may have.
*/
export function sendOutcomeUnknown(err: unknown): boolean {
if (err instanceof ApiError) return err.status >= 500 || err.status === 408;
return true;
}
/**
* What became of a send with this Message-ID.
*
* `sent`: a message carrying it exists and was submitted. `orphan`: it exists
* but no submission does, so it was created and never sent. `none`: nothing.
*
* Submissions are expunged after the server's hold period, so "no submission"
* means unsent only right after the attempt (`justNow`). Later, a message
* still carrying the id is taken as sent: every path that fails a send
* destroys the copy it created.
*/
export async function traceSend(accountId: Id, messageId: string, justNow: boolean): Promise<{ trace: "sent" | "orphan" | "none"; emailIds: Id[] }> {
const q = await client.call<{ ids: Id[] }>("Email/query", { accountId, filter: { header: ["Message-ID", messageId] } });
const emailIds = q.ids ?? [];
if (!emailIds.length) return { trace: "none", emailIds };
if (!justNow) return { trace: "sent", emailIds };
const subs = await client.call<{ ids: Id[] }>("EmailSubmission/query", { accountId, filter: { emailIds } });
return { trace: subs.ids?.length ? "sent" : "orphan", emailIds };
}
async function sendInternal(d: Draft, _get: () => ComposeState, opts: { retry?: boolean } = {}): Promise<Sent> {
const mail = useMail.getState();
const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
@@ -1093,6 +1166,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
const scheduled = d.sendAt !== null && d.sendAt > Date.now();
const scheduledId = scheduled ? await ensureScheduledMailbox() : null;
const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId });
const messageId = d.sendMessageId ?? newMessageId(ident.email);
email.messageId = [messageId];
/*
* Sending again a draft that came back from a failed send: ask first. The
* failure may have been only the reply going missing, and a second
* submission would deliver the message twice.
*/
if (opts.retry) {
let found: Awaited<ReturnType<typeof traceSend>>;
try {
found = await traceSend(accountId, messageId, false);
} catch {
throw new SendOutcomeUnknown(translate("Couldn't check whether this message was already sent. Check Sent before sending it again."));
}
if (found.trace === "sent") {
if (d.draftId) void client.call("Email/set", { accountId, destroy: [d.draftId] });
void mail.loadMailboxes();
void mail.refreshList();
return { held: false, alreadySent: true };
}
}
const sentId = mail.roleId("sent");
const draftsId = mail.roleId("drafts");
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
@@ -1119,7 +1213,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
if (d.relatedEmailId && d.relatedKeyword) {
calls.push(["Email/set", { accountId, update: { [d.relatedEmailId]: { [`keywords/${d.relatedKeyword}`]: true } } }, "k"]);
}
const res = await client.chain(calls, { allowErrors: true });
let res: Awaited<ReturnType<typeof client.chain>>;
try {
res = await client.chain(calls, { allowErrors: true });
} catch (err) {
if (!sendOutcomeUnknown(err)) throw err;
// No answer is not a no: find out what the server did with it.
let found: Awaited<ReturnType<typeof traceSend>>;
try {
found = await traceSend(accountId, messageId, true);
} catch {
throw new SendOutcomeUnknown(translate("Couldn't confirm whether this message was sent. Check Sent before sending it again."));
}
if (found.trace === "sent") {
void mail.loadMailboxes();
void mail.refreshList();
return { held: false };
}
// Created but never submitted: take it out of Sent, then report the failure.
if (found.trace === "orphan") void client.call("Email/set", { accountId, destroy: found.emailIds });
throw err;
}
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
if (e.__error) throw new Error(setErrorMessage(e.__error));
if (e.notCreated?.m) throw new Error(setErrorMessage(e.notCreated.m));
+49 -7
View File
@@ -2,10 +2,12 @@ import { create } from "zustand";
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { contactDisplayName, contactEmails, contactFromAddress, sortKey } from "@/lib/contacts";
import { uniqueAddresses } from "@/lib/address";
import { parseLdif, uidFromDn } from "@/lib/ldif";
import { cardFromLdif } from "@/lib/mozillaAb";
import { useSettings } from "./settings";
import { t as translate } from "@/lib/i18n";
import { useSession } from "./session";
import { useMail } from "./mail";
@@ -225,6 +227,8 @@ interface ContactsState {
*/
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
createBook(name: string): Promise<Id>;
/** Save the addresses that are not on any contact yet, in the Collected address book. */
collectRecipients(addrs: EmailAddress[], own: string[]): Promise<number>;
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
destroyBook(id: Id): Promise<void>;
/** Import vCards, updating any whose UID this book already holds rather than duplicating it. */
@@ -616,6 +620,28 @@ export const useContacts = create<ContactsState>((set, get) => ({
return { destroyed: gone.length, unfiled, refused };
},
async collectRecipients(addrs, own) {
if (!get().available || !get().accountId) return 0;
// Every card has to be known, or someone already a contact gets a second card.
if (!get().loaded) await get().loadAll();
const ownSet = new Set(own.map((e) => e.toLowerCase()));
const fresh = uniqueAddresses(addrs).filter((a) => a.email && !ownSet.has(a.email.toLowerCase()) && !get().lookupByEmail(a.email));
if (!fresh.length) return 0;
const accountId = get().accountId!;
const { settings, update } = useSettings.getState();
let bookId = settings.collectedBookId && get().books[settings.collectedBookId] ? settings.collectedBookId : null;
if (!bookId) {
bookId = await get().createBook(translate("Collected"));
update({ collectedBookId: bookId });
}
const create = Object.fromEntries(
fresh.map((a, i) => [`c${i}`, { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...contactFromAddress(a), addressBookIds: { [bookId!]: true } }]),
);
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
await get().syncCards();
return Object.keys(res.created ?? {}).length;
},
async createBook(name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
@@ -793,14 +819,29 @@ export const useContacts = create<ContactsState>((set, get) => ({
seen.add(k);
out.push(s);
};
const score = (name: string | null, email: string): number => {
/*
* Words in any order: "jane smi" finds "Smith, Jane", and a nickname or the
* organization counts as much as the name. Each word typed has to start a
* word of the person's name, nickname, organization or address.
*/
const tokens = q.split(/\s+/).filter(Boolean);
const wordsOf = (parts: Array<string | null | undefined>) =>
parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean);
const score = (name: string | null, email: string, extra: Array<string | null | undefined> = []): number => {
const n = (name ?? "").toLowerCase();
const e = email.toLowerCase();
if (e.startsWith(q) || n.startsWith(q)) return 0;
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
const words = wordsOf([name, email, ...extra]);
if (tokens.every((t) => words.some((w) => w.startsWith(t)))) return 1;
if (e.includes(q) || n.includes(q)) return 2;
return 99;
};
// Someone written to lately ranks a little above someone not, within the same kind of match.
const recentRank = new Map(st.recent.map((r, i) => [r.email.toLowerCase(), i] as const));
const recency = (email: string) => {
const i = recentRank.get(email.toLowerCase());
return i === undefined ? 0 : -0.3 * (1 - i / Math.max(1, st.recent.length));
};
const candidates: Array<Suggestion & { score: number }> = [];
// A shared address book is only useful if it answers when you are writing
// to someone in it, so its cards are offered alongside the reader's own.
@@ -808,19 +849,20 @@ export const useContacts = create<ContactsState>((set, get) => ({
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
for (const { c, penalty } of [...own, ...shared]) {
const extra = [...Object.values(c.nicknames ?? {}).map((x) => x.name), ...Object.values(c.organizations ?? {}).map((x) => x.name)];
for (const a of contactEmails(c)) {
const sc = score(a.name, a.email);
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
const sc = score(a.name, a.email, extra);
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty + recency(a.email) });
}
}
for (const p of st.principals) {
if (!p.email) continue;
const sc = score(p.name, p.email);
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 + recency(p.email) });
}
for (const r of st.recent) {
const sc = score(r.name, r.email);
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 + recency(r.email) });
}
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
for (const c of candidates) {
+133 -15
View File
@@ -6,10 +6,12 @@ import { accountForCapability, ownAccountForCapability } from "@/lib/accountRout
import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild";
import { unsubscribeThisDevice } from "@/lib/notify/webpush";
import { unsubscribeAccount, unsubscribeThisDevice } from "@/lib/notify/webpush";
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
import { delegationOf, type Delegation } from "@/lib/delegation";
import { withBase } from "@/lib/basePath";
import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail";
export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -19,12 +21,19 @@ interface SessionState {
/** Selected mail account (defaults to primary). */
accountId: Id | null;
/**
* A locked account handed to the reader that the app shows instead of
* their own (inbuxa AL-7): its mail, calendar, contacts and files. The
* reader's settings, filters, signatures and push stay their own.
* Another account whose mail the app shows instead of the reader's own:
* a locked account handed to them (inbuxa AL-7), whose calendar, contacts
* and files follow, or a shared or group mailbox (MA-A), which is mail
* only. The reader's settings, filters, signatures and push stay their own.
*/
viewing: Id | null;
/** The name of an account whose delegation ended while it was in view. */
/** Shared and group mailboxes the reader can open (MA-A); see lib/sharedMail. */
sharedMail: SharedMailAccount[];
/** inbuxa MA-B: the accounts signed in in this browser, the one in front first. */
signedIn: SignedInAccount[];
/** Whether one more may be added (the cap, and both organizations' addAccounts). */
canAddAccount: boolean;
/** The name of an account the reader lost while it was in view. */
delegationEnded: string | null;
error: string | null;
pushConnected: boolean;
@@ -32,11 +41,20 @@ interface SessionState {
pushState: PushState;
bootstrap(): Promise<void>;
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
/** Signs out of the account in front; another signed-in one comes forward. */
logout(): Promise<void>;
/** inbuxa MA-B: ends every account signed in in this browser. */
logoutAll(): Promise<void>;
/** inbuxa MA-B: finds the other accounts signed in here. */
loadSignedIn(): Promise<void>;
/** inbuxa MA-B: brings another signed-in account to the front, and reloads. */
switchTo(sessionId: string): Promise<void>;
refresh(): Promise<void>;
setAccount(id: Id): void;
/** Show a delegated account's mail, or the reader's own with null. */
/** Show a delegated account's or shared mailbox's mail, or the reader's own with null. */
view(id: Id | null): void;
/** Finds the shared and group mailboxes the reader can open. */
loadSharedMail(): Promise<void>;
clearDelegationEnded(): void;
/** The account to read and write for a capability, honoring the account switcher. */
accountFor(cap: string): Id | null;
@@ -53,11 +71,49 @@ interface SessionState {
let refreshing: Promise<void> | null = null;
/** What a signed-in account looks like in the switcher (MA-B). */
export interface SignedInAccount {
id: string;
username: string;
front: boolean;
/** Its mail account, for its push subscription (MA-8); null when not known yet. */
mailAccountId?: string | null;
}
/**
* inbuxa MA-8: a notification for an account not in front opens
* `?account=<session>&next=<where>`: bring that account forward, then go
* there. Only a path inside the app is followed.
*/
// Read as the app starts: the router sends `/` on to `/mail` without its query.
let launchParams: URLSearchParams | null = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
async function openFromNotification(accounts: SignedInAccount[]): Promise<void> {
const params = launchParams;
launchParams = null;
const wanted = params?.get("account");
if (!params || !wanted) return;
const raw = params.get("next") ?? "";
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : withBase("/mail");
const account = accounts.find((a) => a.id === wanted);
if (account && !account.front) {
await apiFetch(`/api/auth/accounts/${encodeURIComponent(wanted)}/front`, { method: "POST" });
clearSignedInData();
}
window.location.replace(next);
}
/** Which sign-out: the account in front, or every one (MA-B). */
let signOutPath = "/api/auth/logout";
export const useSession = create<SessionState>((set, get) => ({
status: "loading",
session: null,
accountId: null,
viewing: null,
sharedMail: [],
signedIn: [],
canAddAccount: false,
delegationEnded: null,
error: null,
pushConnected: false,
@@ -96,7 +152,19 @@ export const useSession = create<SessionState>((set, get) => ({
// without removing it leaves this browser notifying for a mailbox nobody is
// signed into. On a shared machine that is somebody else's mail.
try {
await unsubscribeThisDevice();
const everyone = signOutPath.endsWith("logout-all");
const othersRemain = !everyone && get().signedIn.some((a) => !a.front);
if (everyone) {
// inbuxa MA-8: every account's subscription goes, the others' first
const { unsubscribeOtherAccounts } = await import("@/lib/notify/webpushEnable");
await unsubscribeOtherAccounts();
}
if (othersRemain) {
// inbuxa MA-8: only this account's; the browser keeps notifying for the rest
await unsubscribeAccount(undefined, get().ownAccountFor(CAP.mail));
} else {
await unsubscribeThisDevice();
}
} catch {
/* never block signing out over this */
}
@@ -109,8 +177,11 @@ export const useSession = create<SessionState>((set, get) => ({
} catch {
/* never block signing out over this */
}
const path = signOutPath;
signOutPath = "/api/auth/logout";
let next = false;
try {
await apiFetch("/api/auth/logout", { method: "POST" });
next = Boolean((await apiFetch<{ next?: boolean }>(path, { method: "POST" }))?.next);
} catch {
/* ignore */
}
@@ -120,7 +191,35 @@ export const useSession = create<SessionState>((set, get) => ({
// problem next -- and the address book cached here is the same argument.
clearSignedInData();
client.session = null;
set({ status: "anonymous", session: null, accountId: null, viewing: null });
// inbuxa MA-B: another signed-in account is in front now
if (next) {
window.location.reload();
return;
}
set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [], signedIn: [], canAddAccount: false });
},
async logoutAll() {
// The same care as signing out of one, then every account ends
signOutPath = "/api/auth/logout-all";
await get().logout();
},
async loadSignedIn() {
try {
const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts");
set({ signedIn: answer.accounts, canAddAccount: answer.canAdd });
await openFromNotification(answer.accounts);
} catch {
set({ signedIn: [], canAddAccount: false });
}
},
async switchTo(sessionId) {
await apiFetch(`/api/auth/accounts/${encodeURIComponent(sessionId)}/front`, { method: "POST" });
// What was cached belongs to the account that was in front
clearSignedInData();
window.location.reload();
},
refresh() {
@@ -130,14 +229,16 @@ export const useSession = create<SessionState>((set, get) => ({
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
client.session = s;
setServerLocale(s.ihasmail?.userLocale);
// A delegation that ended takes the reader back to their own mail
// A delegation that ended, or a shared mailbox taken away, takes the
// reader back to their own mail
const viewing = get().viewing;
if (viewing && !delegationOf(s, viewing)) {
if (viewing && !delegationOf(s, viewing) && !sharedMailCandidates(s).some((a) => a.id === viewing)) {
const name = get().session?.accounts[viewing]?.name ?? null;
set({ session: s, viewing: null, delegationEnded: name });
} else {
set({ session: s });
}
void get().loadSharedMail();
} catch {
/* ignore */
} finally {
@@ -152,11 +253,18 @@ export const useSession = create<SessionState>((set, get) => ({
},
view(id) {
if (id && !delegationOf(get().session, id)) return;
if (id && !delegationOf(get().session, id) && !get().sharedMail.some((a) => a.id === id)) return;
if (id === get().viewing) return;
set({ viewing: id });
},
async loadSharedMail() {
const session = get().session;
const found = await findSharedMail(session);
// A sign-out or another account's session arrived while it was asking
if (get().session === session) set({ sharedMail: found });
},
clearDelegationEnded() {
set({ delegationEnded: null });
},
@@ -172,7 +280,8 @@ export const useSession = create<SessionState>((set, get) => ({
viewAccountFor(cap) {
const { session, viewing } = get();
const viewed = viewing ? session?.accounts[viewing] : undefined;
if (viewing && viewed && cap in (viewed.accountCapabilities ?? {})) return viewing;
// A shared or group mailbox in view is mail only (MA-A, MA-S)
if (viewing && viewed && delegationOf(session, viewing)?.kind === "lock" && cap in (viewed.accountCapabilities ?? {})) return viewing;
return ownAccountForCapability(session, cap);
},
}));
@@ -194,7 +303,9 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
startIdleLogout(() => void useSession.getState().logout());
}
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
set({ status: "authenticated", session: s, accountId, viewing: null, error: null });
set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null });
void useSession.getState().loadSharedMail();
void useSession.getState().loadSignedIn();
}
client.onUnauthenticated(() => {
@@ -207,7 +318,7 @@ client.onUnauthenticated(() => {
// usual reason to be signed out here, and reloading a form someone has
// already started typing into would throw the password away.
void reloadIfServerRebuilt().then((reloading) => {
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null });
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] });
});
});
@@ -220,6 +331,13 @@ export function useViewingDelegation(): Delegation | null {
return delegationOf(session, viewing);
}
/** The shared or group mailbox in view, if one is (MA-A). */
export function useViewingShared(): SharedMailAccount | null {
const viewing = useSession((s) => s.viewing);
const sharedMail = useSession((s) => s.sharedMail);
return (viewing && sharedMail.find((a) => a.id === viewing)) || null;
}
export function viewingDelegation(): Delegation | null {
const s = useSession.getState();
return delegationOf(s.session, s.viewing);
+11
View File
@@ -206,6 +206,15 @@ export interface Settings {
* dates nobody put there is a surprise rather than a feature.
*/
birthdayCalendar: boolean;
/**
* Save everyone written to who is not a contact yet, in an address book of
* its own, so they are suggested on every device rather than only in the
* browser that sent the message. On by default, as mail clients do; the
* address book can be emptied or deleted like any other.
*/
collectRecipients: boolean;
/** The address book collected recipients go to, once there is one. */
collectedBookId: string | null;
/**
* Calendars subscribed to by URL. The subscription is the setting; the
* events themselves are fetched on demand and never stored, so this follows
@@ -361,6 +370,8 @@ export const DEFAULT_SETTINGS: Settings = {
timeZone: null,
labelsSidebar: true,
birthdayCalendar: false,
collectRecipients: true,
collectedBookId: null,
icalSubscriptions: [],
listSortPreset: "newest",
listSortLevels: [],
+49
View File
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// Read off disk: `?raw` comes back empty for a stylesheet under the test runner.
const css = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../app.css"), "utf8");
/*
* The app rail, the collapsed sidebar and the raised surfaces are all CSS, and
* jsdom does no layout, so these pin the rules themselves. Contrast for the
* pill accent was measured in a browser across every palette, accent and
* mode; the shape of that rule is pinned here.
*/
const rule = (selector: string) => {
const i = css.indexOf(selector + " {");
expect(i, `no rule for ${selector}`).toBeGreaterThan(-1);
return css.slice(i, css.indexOf("}", i));
};
describe("app layout CSS", () => {
it("gives the rail its own column ahead of the sidebar, collapsed or not", () => {
expect(rule(".app-body.has-rail")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w\)/);
expect(rule(".app-body.has-rail.collapsed")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w-collapsed\)/);
});
it("hides a sidebar with no icon-only form instead of crushing it", () => {
expect(rule(".app-body.has-rail.sidebar-hidden")).toMatch(/grid-template-columns:\s*56px 0 /);
expect(rule(".app-body.sidebar-hidden > .sidebar")).toMatch(/visibility:\s*hidden/);
});
it("hides section headings when collapsed, over the sidebar's own display rule", () => {
// `.sidebar .nav-section { display: flex }` comes later at the same weight;
// the collapsed rule has to outweigh it or "FOLDERS" is drawn cut off.
expect(css).toMatch(/\.app-body\.collapsed \.sidebar \.nav-section[^{]*\{\s*display:\s*none/);
});
it("darkens the pill accent in light themes and lightens it in dark ones", () => {
expect(css).toMatch(/:root \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, black\); \}/);
expect(css).toMatch(/:root\[data-theme="dark"\] \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, white\); \}/);
});
it("lets a color the reader picked win over the role tint", () => {
for (const role of ["inbox", "drafts", "sent", "archive", "junk", "trash", "scheduled"]) {
expect(css).toContain(`.folder-icon[data-role="${role}"]:not([style*="--folder-color"]) svg`);
}
});
});
+81 -1
View File
@@ -1257,6 +1257,10 @@ a.menu-item:hover { color: var(--fg); }
.app.delegated { grid-template-rows: auto var(--topbar-h) 1fr; }
.delegated-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #b91c1c; color: #fff; font-size: .9em; min-width: 0; }
.delegated-bar strong { font-weight: 700; }
/* MA-A: a shared or group mailbox is the reader's to be in, so the palette's own color, not the locked account's red. */
.delegated-bar.shared { background: var(--accent); color: var(--accent-fg); }
/* MA-8: new mail in another signed-in account, on the avatar */
.acct-dot { position: absolute; top: 0; right: 0; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
.delegated-bar button { flex: none; border: 1px solid rgba(255, 255, 255, .7); background: transparent; color: #fff; border-radius: 999px; padding: 3px 12px; font: inherit; font-weight: 600; cursor: pointer; }
.delegated-bar button:hover, .delegated-bar button:focus-visible { background: rgba(255, 255, 255, .15); }
.topbar .brand.locked .brand-name, .topbar .brand.locked .brand-lock { color: #dc2626; }
@@ -1316,7 +1320,7 @@ a.menu-item:hover { color: var(--fg); }
.collapsed .compose-btn span { display: none; }
.nav-section { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; font-size: .75em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-faint); }
.nav-section .icon-btn { width: 24px; height: 24px; }
.collapsed .nav-section { display: none; }
.collapsed .nav-section, .app-body.collapsed .sidebar .nav-section { display: none; }
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; height: 36px; padding: 0 12px; border-radius: 0 999px 999px 0; margin-right: 8px; color: var(--fg); text-decoration: none; white-space: nowrap; transition: background .12s; user-select: none; font-weight: 450; }
.nav-item:hover { background: var(--bg-hover); }
.nav-item.unread .nav-label, .nav-item.unread .nav-count { font-weight: 700; color: var(--fg); }
@@ -1383,6 +1387,82 @@ a.menu-item:hover { color: var(--fg); }
.collapsed .nav-item .nav-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.nav-label-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
/* Outlook-style module bar at the bottom of the sidebar */
/* App rail. A fixed column left of the sidebar on wide screens; phones keep the tab bar. */
.app-body.has-rail { grid-template-columns: 56px var(--sidebar-w) minmax(0, 1fr); }
.app-body.has-rail.collapsed { grid-template-columns: 56px var(--sidebar-w-collapsed) minmax(0, 1fr); }
.app-body.has-rail > .sidebar-splitter { left: calc(56px + var(--sidebar-w) - 3px); }
/* Only Mail's folder list has an icon-only form. Calendar, Contacts, Files, Settings and Admin
hide their sidebar outright when collapsed; the rail still navigates and its toggle brings it back. */
.app-body.has-rail.sidebar-hidden { grid-template-columns: 56px 0 minmax(0, 1fr); }
.app-body.sidebar-hidden > .sidebar { visibility: hidden; padding: 0; min-width: 0; }
.app-rail { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 0 12px; border-right: 1px solid var(--border); min-height: 0; }
.app-rail-spacer { flex: 1; }
.rail-toggle { background: none; border: 0; cursor: pointer; margin-top: 4px; }
.rail-link { position: relative; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 12px; color: var(--fg-muted); transition: background .12s, color .12s; }
.rail-link:hover { background: var(--bg-hover); color: var(--fg); }
.rail-link.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
.rail-badge { position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--pill-accent); color: var(--accent-fg); font-size: 10.5px; font-weight: 700; line-height: 18px; text-align: center; box-shadow: 0 0 0 2px var(--bg); }
/* Pill counts. Accent-filled when the folder has unread mail, neutral for totals (Drafts, Scheduled). */
.nav-item .nav-count { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 20px; padding: 0 7px; border-radius: 999px; background: var(--bg-active); color: var(--fg-muted); font-size: .74em; font-weight: 650; font-variant-numeric: tabular-nums; }
/* The accent as a fill behind small text (pills, the rail badge, Compose). The plain accent is
a mid tone that white text does not read on in light themes, so it is darkened there and
lightened a touch in dark ones: at least 4.5:1 in all 12 palettes x 6 accents x 2 modes,
measured 2026-10-05 (lowest 4.83). */
:root { --pill-accent: color-mix(in srgb, var(--accent) 70%, black); }
:root[data-theme="dark"] { --pill-accent: color-mix(in srgb, var(--accent) 88%, white); }
.nav-item.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); font-weight: 700; }
.nav-item.active .nav-count, .nav-item.active.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .25); }
.collapsed .nav-item .nav-count { display: none; }
/* Raised surfaces. A top-lit sheen, a 1px inner highlight on top and shade below, and a small drop,
so buttons and the current selection read as pieces standing off the page. Tuned per theme lightness. */
:root {
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .04) 50%, rgb(0 0 0 / .06));
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .32), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .14));
--raise-hi: rgb(255 255 255 / .75);
--raise-lo: rgb(0 0 0 / .12);
--raise-drop: 0 1px 2px rgb(0 0 0 / .14), 0 3px 8px rgb(0 0 0 / .09);
--raise-press: inset 0 1px 2px rgb(0 0 0 / .14);
}
:root[data-theme="dark"] {
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .14), rgb(255 255 255 / .02) 50%, rgb(0 0 0 / .16));
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .22));
--raise-hi: rgb(255 255 255 / .16);
--raise-lo: rgb(0 0 0 / .45);
--raise-drop: 0 1px 2px rgb(0 0 0 / .55), 0 3px 10px rgb(0 0 0 / .32);
--raise-press: inset 0 1px 3px rgb(0 0 0 / .45);
}
.btn, .compose-btn, .rail-link.active, .nav-item.active, .module-link.active, .segmented button.active {
background-image: var(--raise-sheen);
box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo), var(--raise-drop);
}
.btn-primary, .rail-badge { background-image: var(--raise-sheen-strong); }
.compose-btn, .compose-btn:hover { background-color: var(--pill-accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
.compose-btn svg { color: inherit; }
.compose-btn, .rail-link.active { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--raise-drop); }
.compose-btn:hover { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--shadow-2); filter: brightness(1.05); }
.rail-link.active, .rail-link.active:hover { background-color: var(--accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
.btn-primary { box-shadow: inset 0 1px 0 rgb(255 255 255 / .22), inset 0 -1px 0 rgb(0 0 0 / .18), var(--raise-drop); }
.btn:active, .compose-btn:active, .rail-link:active, .nav-item:active, .module-link:active, .segmented button:active {
transform: translateY(1px);
box-shadow: var(--raise-press);
background-image: none;
}
/* The segmented control clips its children; the raise sits inside its border. */
.segmented button.active { box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo); }
/* Tinted role-folder icons. A color the reader picked for a folder still wins (style attribute, fill rule above). */
:root { --role-inbox: #2f6fe4; --role-drafts: #8a5cd6; --role-sent: #138a6c; --role-archive: #976214; --role-junk: #b8501c; --role-trash: #c93c4b; --role-scheduled: #0f8aa6; }
:root[data-theme="dark"] { --role-inbox: #6ea2ff; --role-drafts: #b796f5; --role-sent: #4fcfa6; --role-archive: #e5b45a; --role-junk: #f2925f; --role-trash: #f07a87; --role-scheduled: #4fc3dc; }
.folder-row .folder-icon[data-role="inbox"]:not([style*="--folder-color"]) svg { color: var(--role-inbox); }
.folder-row .folder-icon[data-role="drafts"]:not([style*="--folder-color"]) svg { color: var(--role-drafts); }
.folder-row .folder-icon[data-role="sent"]:not([style*="--folder-color"]) svg { color: var(--role-sent); }
.folder-row .folder-icon[data-role="archive"]:not([style*="--folder-color"]) svg { color: var(--role-archive); }
.folder-row .folder-icon[data-role="junk"]:not([style*="--folder-color"]) svg { color: var(--role-junk); }
.folder-row .folder-icon[data-role="trash"]:not([style*="--folder-color"]) svg { color: var(--role-trash); }
.folder-row .folder-icon[data-role="scheduled"]:not([style*="--folder-color"]) svg { color: var(--role-scheduled); }
.module-bar { display: flex; align-items: stretch; justify-content: space-around; gap: 2px; padding: 6px 4px 4px; border-top: 1px solid var(--border); flex: 0 0 auto; }
.module-link { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; flex: 1; min-width: 0; padding: 6px 2px; border-radius: var(--radius-sm); color: var(--fg-muted); text-decoration: none; font-size: 11px; font-weight: 500; transition: background .12s, color .12s; }
.module-link:hover { background: var(--bg-hover); color: var(--fg); }
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState, type FormEvent } from "react";
import { Dialog } from "@/ui/dialog";
import { apiFetch } from "@/jmap/client";
import { withBase } from "@/lib/basePath";
import { clearSignedInData, isDeviceTrusted } from "@/lib/storage";
import { t } from "@/lib/i18n";
/**
* inbuxa MA-B: sign a second account in beside the one in front.
*
* With sign-in on the mail server's own page and one mail server, there is
* nothing to ask here: the browser goes straight to that page, which asks for
* the account. With several servers the address comes first, to pick one; with
* the password form, so does the password. Either way the account joins the
* others and comes to the front, and the app reloads into it.
*
* It is remembered on this device exactly as the first one was (MA-7).
*/
export function AddAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const [mode, setMode] = useState<"oauth" | "oauth-address" | "password" | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const remember = isDeviceTrusted();
useEffect(() => {
if (!open) return;
setError(null);
let live = true;
fetch(withBase("/api/config"))
.then((r) => (r.ok ? r.json() : null))
.then((c) => {
if (!live) return;
const oauth = c?.signIn === "oauth";
const next = oauth ? (c?.signInDirect === true ? "oauth" : "oauth-address") : "password";
setMode(next);
// Nothing to ask: off to the mail server's page
if (next === "oauth") goToServer("");
})
.catch(() => live && setMode("password"));
return () => {
live = false;
};
// goToServer only reads `remember`, fixed for the dialog's life
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
function goToServer(address: string) {
setBusy(true);
// What is cached belongs to the account in front, which won't be
clearSignedInData();
const params = new URLSearchParams({ add: "1", ...(address ? { username: address } : {}), ...(remember ? { remember: "1" } : {}) });
window.location.assign(withBase(`/api/auth/oauth/start?${params}`));
}
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!username.trim()) return;
if (mode === "oauth-address") return goToServer(username.trim());
if (!password) return;
setBusy(true);
setError(null);
try {
await apiFetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: username.trim(), password, remember, add: true }),
});
clearSignedInData();
window.location.reload();
} catch (err) {
setError((err as Error).message || t("That account couldn't be added."));
setBusy(false);
}
};
if (mode === "oauth") return null;
return (
<Dialog
open={open}
onClose={onClose}
title={t("Add an account")}
size="sm"
footer={
<>
<button className="btn btn-ghost" onClick={onClose} disabled={busy}>
{t("Cancel")}
</button>
<button className="btn btn-primary" form="add-account" type="submit" disabled={busy || !username.trim() || (mode === "password" && !password)}>
{busy ? t("Working…") : t("Add account")}
</button>
</>
}
>
<form id="add-account" onSubmit={(e) => void submit(e)}>
<p className="hint">{t("Both accounts stay signed in here; switch between them from this menu.")}</p>
<div className="field">
<label htmlFor="add-account-user">{t("Email address")}</label>
<input id="add-account-user" autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
</div>
{mode === "password" && (
<div className="field">
<label htmlFor="add-account-pw">{t("Password")}</label>
<input id="add-account-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
)}
{error && <p className="error">{error}</p>}
</form>
</Dialog>
);
}
+145 -14
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session";
import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -11,6 +11,8 @@ import { composeBlocked, draftFromMailto, useCompose } from "@/store/compose";
import { delegatedAccounts } from "@/lib/delegation";
import { toast } from "@/ui/toast";
import { DelegatedBar } from "./DelegatedBar";
import { AddAccountDialog } from "./AddAccountDialog";
import { useOtherAccountsUnread, useOtherUnread } from "@/lib/otherAccounts";
import { Avatar, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Splitter } from "@/ui/Splitter";
@@ -52,6 +54,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const [location, navigate] = useLocation();
const isMobile = useIsMobile();
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
const inboxUnread = useMail((s) => Object.values(s.mailboxes).find((m) => m.role === "inbox")?.unreadEmails ?? 0);
const sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
const update = useSettings((s) => s.update);
/*
@@ -72,6 +75,39 @@ export function AppShell({ children }: { children: ReactNode }) {
const pushState = useSession((s) => s.pushState);
const session = useSession((s) => s.session);
const logout = useSession((s) => s.logout);
// inbuxa MA-B: the other accounts signed in here, and adding one
const signedIn = useSession((s) => s.signedIn);
const canAddAccount = useSession((s) => s.canAddAccount);
const [addingAccount, setAddingAccount] = useState(false);
// inbuxa MA-8: new mail in the accounts not in front
useOtherAccountsUnread();
const otherUnread = useOtherUnread((s) => s.unread);
const anyOtherUnread = Object.values(otherUnread).some((n) => n > 0);
const bringForward = (sessionId: string) => {
acctMenu.close();
// A message being written belongs to the account it was started in
if (useCompose.getState().drafts.length) {
toast.show(t("Send or close the message you're writing first."));
return;
}
void useSession.getState().switchTo(sessionId);
};
// A refused add comes back on the address (see the server's /auth/callback)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const why = params.get("account_error");
if (!why) return;
toast.show(
why === "add_full"
? t("You can't add more accounts here.")
: why === "add_other_server"
? t("That account is on another mail server. Only accounts on this server can be added.")
: t("Your organization doesn't allow adding other accounts here."),
);
params.delete("account_error");
const rest = params.toString();
window.history.replaceState(null, "", window.location.pathname + (rest ? `?${rest}` : ""));
}, []);
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
const acctMenu = useMenu();
const administers = hasAdministration(usePermissions());
@@ -149,6 +185,9 @@ export function AppShell({ children }: { children: ReactNode }) {
*/
const viewing = useSession((s) => s.viewing);
const delegated = delegatedAccounts(session);
// MA-A: shared and group mailboxes are offered in the same list, after locked accounts
const sharedMail = useSession((s) => s.sharedMail);
const lockedInView = viewing !== null && delegated.some((a) => a.id === viewing);
const switchTo = (id: string | null) => {
acctMenu.close();
if (id === viewing) return;
@@ -166,12 +205,15 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className={`app ${viewing ? "delegated" : ""}`}>
<DelegatedBar />
<header className="topbar">
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
<MenuIcon size={22} />
</button>
<Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
{/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */}
{isMobile && (
<button className="icon-btn" aria-label={t("Menu")} onClick={() => setDrawer((d) => !d)}>
<MenuIcon size={22} />
</button>
)}
<Link href="/mail" className={`brand ${lockedInView ? "locked" : ""}`}>
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
{viewing && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
{lockedInView && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
{/* A product name, not a word: translated it is a different product.
Read from the session rather than written here, so a deployment
that set APP_NAME is called what it calls itself -- the document
@@ -193,11 +235,21 @@ export function AppShell({ children }: { children: ReactNode }) {
<HelpCircle size={21} />
</button>
<ThemeToggle />
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
<Settings size={21} />
</Link>
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
{/* On a wide screen the rail carries Settings; phones have no rail, so they keep this one. */}
{isMobile && (
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
<Settings size={21} />
</Link>
)}
<button
className="icon-btn"
style={{ width: "auto", padding: "0 2px", borderRadius: 999, position: "relative" }}
onClick={acctMenu.open}
aria-label={anyOtherUnread ? t("Account: new mail in another account") : t("Account")}
>
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
{/* inbuxa MA-8: another signed-in account has unread mail */}
{anyOtherUnread && <span className="acct-dot" aria-hidden />}
</button>
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
@@ -210,7 +262,43 @@ export function AppShell({ children }: { children: ReactNode }) {
</div>
</div>
<MenuSep />
{delegated.length > 0 && (
{/* inbuxa MA-B: each account signed in here, the one in front ticked */}
{(signedIn.length > 1 || canAddAccount) && (
<>
{signedIn.length > 1 && <div className="hint" style={{ padding: "4px 10px" }}>{t("Accounts")}</div>}
{signedIn.length > 1 &&
signedIn.map((account) => (
<MenuItem
key={account.id}
icon={account.front ? <Check size={16} /> : <Mail size={16} />}
label={
<span className="row" style={{ gap: 8, alignItems: "center" }}>
<span className="notranslate grow truncate" translate="no">{account.username}</span>
{!account.front && (otherUnread[account.id] ?? 0) > 0 && (
<span className="nav-count" aria-label={t("Unread in the Inbox: {count}", { count: otherUnread[account.id]! })}>
{otherUnread[account.id]! > 9999 ? "9999+" : otherUnread[account.id]}
</span>
)}
</span>
}
active={account.front}
onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))}
/>
))}
{canAddAccount && (
<MenuItem
icon={<Plus size={16} />}
label={t("Add account")}
onClick={() => {
acctMenu.close();
setAddingAccount(true);
}}
/>
)}
<MenuSep />
</>
)}
{delegated.length + sharedMail.length > 0 && (
<>
<div className="hint" style={{ padding: "4px 10px" }}>{t("Mail to show")}</div>
<MenuItem
@@ -228,6 +316,15 @@ export function AppShell({ children }: { children: ReactNode }) {
onClick={() => switchTo(account.id)}
/>
))}
{sharedMail.map((account) => (
<MenuItem
key={account.id}
icon={viewing === account.id ? <Check size={16} /> : <Users size={16} />}
label={<span className="notranslate" translate="no">{account.name}</span>}
active={viewing === account.id}
onClick={() => switchTo(account.id)}
/>
))}
<MenuSep />
</>
)}
@@ -258,15 +355,39 @@ export function AppShell({ children }: { children: ReactNode }) {
)}
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
{signedIn.length > 1 && (
<MenuItem icon={<LogOut size={16} />} label={t("Sign out of all accounts")} onClick={() => void useSession.getState().logoutAll()} />
)}
</Popover>
<AddAccountDialog open={addingAccount} onClose={() => setAddingAccount(false)} />
</div>
</header>
<div
className={`app-body ${collapsed && !isMobile ? "collapsed" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
className={`app-body ${!isMobile ? "has-rail" : ""} ${collapsed && !isMobile ? "collapsed" : ""} ${collapsed && !isMobile && section !== "mail" && section !== "search" ? "sidebar-hidden" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
>
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
{!isMobile && (
<nav className="app-rail" aria-label={t("Go to")}>
<RailLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} badge={inboxUnread} />
<RailLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
<RailLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
<RailLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
<span className="app-rail-spacer" />
<RailLink href="/settings" icon={<Settings size={20} />} label={t("Settings")} active={section === "settings"} />
<button
type="button"
className="rail-link rail-toggle"
aria-label={collapsed ? t("Show folder list") : t("Hide folder list")}
title={collapsed ? t("Show folder list") : t("Hide folder list")}
aria-expanded={!collapsed}
onClick={() => update({ sidebarCollapsed: !collapsed })}
>
{collapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
</button>
</nav>
)}
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
{/*
The way back out.
@@ -316,12 +437,12 @@ export function AppShell({ children }: { children: ReactNode }) {
{section === "admin" && <AdminNav />}
</div>
{(section === "mail" || section === "search") && <QuotaBar />}
<nav className="module-bar" aria-label={t("Go to")}>
{isMobile && <nav className="module-bar" aria-label={t("Go to")}>
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
</nav>
</nav>}
</aside>
{/* Not on a phone, where the sidebar is a drawer over the page, and not
while collapsed to icons, where there is no width to choose. */}
@@ -406,6 +527,16 @@ export function AppShell({ children }: { children: ReactNode }) {
);
}
/** The app rail down the left edge, which replaces the module bar on a wide screen. */
function RailLink({ href, icon, label, active, badge = 0 }: { href: string; icon: ReactNode; label: string; active: boolean; badge?: number }) {
return (
<Link href={href} className={`rail-link ${active ? "active" : ""}`} title={label} aria-label={label} aria-current={active ? "page" : undefined}>
{icon}
{badge > 0 && <span className="rail-badge">{badge > 999 ? "999+" : badge}</span>}
</Link>
);
}
/** Outlook-style module switcher at the bottom of the folder pane. */
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
return (
+25 -9
View File
@@ -1,6 +1,6 @@
import { Lock } from "lucide-react";
import { Lock, Users } from "lucide-react";
import { useLocation } from "wouter";
import { useSession, useViewingDelegation } from "@/store/session";
import { useSession, useViewingDelegation, useViewingShared } from "@/store/session";
import { t } from "@/lib/i18n";
import type { DelegationAccess } from "@/lib/delegation";
@@ -25,7 +25,29 @@ export function DelegatedBar() {
const viewing = useSession((s) => s.viewing);
const name = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
const delegation = useViewingDelegation();
const shared = useViewingShared();
const [, navigate] = useLocation();
const back = () => {
useSession.getState().view(null);
navigate("/mail");
};
// MA-A: a shared or group mailbox in view says whose it is, with the same way back
if (viewing && shared) {
return (
<div className="delegated-bar shared" role="status">
<Users size={15} aria-hidden />
<span className="grow truncate">
{t("Shared mailbox:")} <strong className="notranslate" translate="no">{shared.name}</strong>
{/* MA-S: one an administrator assigned says at what level */}
{delegation ? ` · ${accessText(delegation.access)}` : ""}
{delegation?.sendAs ? ` · ${t("You can send as this account")}` : ""}
</span>
<button type="button" onClick={back}>
{t("Back to my mail")}
</button>
</div>
);
}
if (!viewing || !delegation) return null;
return (
<div className="delegated-bar" role="status">
@@ -36,13 +58,7 @@ export function DelegatedBar() {
{accessText(delegation.access)}
{delegation.sendAs ? ` · ${t("You can send as this account")}` : ""}
</span>
<button
type="button"
onClick={() => {
useSession.getState().view(null);
navigate("/mail");
}}
>
<button type="button" onClick={back}>
{t("Back to my mail")}
</button>
</div>
+3 -3
View File
@@ -248,7 +248,7 @@ export function Composer({ draft }: { draft: Draft }) {
already knows the name they are half-way through typing. */}
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button>
</label>
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
<RecipientInput id={`${key}-to`} value={d.to} exclude={[...d.cc, ...d.bcc]} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
<span className="field-extra">
{/* Beside Cc and Bcc, because that is where someone looks when
they are thinking about who the message goes to. The label
@@ -268,13 +268,13 @@ export function Composer({ draft }: { draft: Draft }) {
{d.showCc && (
<div className="composer-field">
<label htmlFor={`${key}-cc`}>{translate("Cc")}</label>
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
<RecipientInput id={`${key}-cc`} value={d.cc} exclude={[...d.to, ...d.bcc]} onChange={(cc) => patch({ cc })} />
</div>
)}
{d.showBcc && (
<div className="composer-field">
<label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label>
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
<RecipientInput id={`${key}-bcc`} value={d.bcc} exclude={[...d.to, ...d.cc]} onChange={(bcc) => patch({ bcc })} />
</div>
)}
<div className="composer-field">
+5 -3
View File
@@ -12,9 +12,11 @@ interface Props {
placeholder?: string;
autoFocus?: boolean;
id?: string;
/** Addresses already in the message's other recipient fields, not to be suggested again. */
exclude?: EmailAddress[];
}
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) {
export function RecipientInput({ value, onChange, placeholder, autoFocus, id, exclude }: Props) {
const [text, setText] = useState("");
const [sugg, setSugg] = useState<Suggestion[]>([]);
const [active, setActive] = useState(0);
@@ -34,7 +36,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
const t = window.setTimeout(() => {
void suggest(q).then((list) => {
if (id !== reqId.current) return;
const existing = new Set(value.map((v) => v.email.toLowerCase()));
const existing = new Set([...value, ...(exclude ?? [])].map((v) => v.email.toLowerCase()));
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
setSugg(filtered);
setActive(0);
@@ -42,7 +44,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
});
}, 120);
return () => window.clearTimeout(t);
}, [text, suggest, value]);
}, [text, suggest, value, exclude]);
const commit = (raw?: string) => {
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
+28 -3
View File
@@ -25,6 +25,15 @@ interface Props {
imageUpload?: (file: File) => Promise<string>;
}
/** Pasted or dropped images larger than this go in as attachments, not inline. */
export const INLINE_IMAGE_MAX = 10 * 1024 * 1024;
/** A single http(s) or mailto address, nothing else: what may become a link over a selection. */
export function isLinkToPaste(text: string): boolean {
if (!text || /\s/.test(text)) return false;
return /^(https?:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/i.test(text);
}
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
@@ -133,10 +142,25 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
const f = imgItem.getAsFile();
if (f) {
e.preventDefault();
insertImageFile(f);
// A large image is a file to send, not a picture in the text: it goes
// in as an attachment, where an inline copy would swell every reply.
if (f.size > INLINE_IMAGE_MAX && onFiles) onFiles([f]);
else insertImageFile(f);
return;
}
}
/*
* A link pasted over selected words makes those words the link, the way
* most editors do, instead of replacing them with the address.
*/
const pastedText = e.clipboardData.getData("text/plain").trim();
const sel = window.getSelection();
if (isLinkToPaste(pastedText) && sel && !sel.isCollapsed && elRef.current?.contains(sel.anchorNode)) {
e.preventDefault();
document.execCommand("createLink", false, pastedText);
emit();
return;
}
const htmlData = e.clipboardData.getData("text/html");
if (htmlData) {
e.preventDefault();
@@ -175,8 +199,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
const files = Array.from(e.dataTransfer.files);
if (!files.length) return;
e.preventDefault();
const images = files.filter((f) => f.type.startsWith("image/"));
const others = files.filter((f) => !f.type.startsWith("image/"));
const inline = (f: File) => f.type.startsWith("image/") && !(f.size > INLINE_IMAGE_MAX && onFiles);
const images = files.filter(inline);
const others = files.filter((f) => !inline(f));
images.forEach(insertImageFile);
if (others.length) onFiles?.(others);
};
@@ -0,0 +1,102 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { INLINE_IMAGE_MAX, RichEditor, isLinkToPaste } from "../RichEditor";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
/*
* Two things pasting into a message now does: a link pasted over selected
* words turns those words into the link, and an image too large to belong
* in the text goes in as an attachment. jsdom does no editing, so these check
* the command and the callback the editor reaches for.
*/
describe("what counts as a link to paste", () => {
it("takes one web or mailto address", () => {
expect(isLinkToPaste("https://example.com/a?b=1")).toBe(true);
expect(isLinkToPaste("http://example.com")).toBe(true);
expect(isLinkToPaste("mailto:[email protected]")).toBe(true);
});
it("leaves ordinary text and anything with spaces alone", () => {
expect(isLinkToPaste("see https://example.com")).toBe(false);
expect(isLinkToPaste("example.com")).toBe(false);
expect(isLinkToPaste("javascript:alert(1)")).toBe(false);
expect(isLinkToPaste("")).toBe(false);
});
});
describe("pasting into the editor", () => {
let host: HTMLDivElement;
let root: Root;
let onFiles: ReturnType<typeof vi.fn<(files: File[]) => void>>;
let exec: ReturnType<typeof vi.fn>;
beforeEach(async () => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
onFiles = vi.fn<(files: File[]) => void>();
exec = vi.fn(() => true);
(document as unknown as { execCommand: unknown }).execCommand = exec;
await act(async () => {
root.render(<RichEditor html="<p>read the docs here</p>" onChange={() => {}} onFiles={onFiles} showToolbar={false} />);
});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
const paste = async (data: { text?: string; html?: string; files?: File[] }) => {
const items = (data.files ?? []).map((f) => ({ type: f.type, kind: "file", getAsFile: () => f }));
const ev = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(ev, "clipboardData", {
value: { items, getData: (t: string) => (t === "text/plain" ? data.text ?? "" : t === "text/html" ? data.html ?? "" : "") },
});
await act(async () => editor().dispatchEvent(ev));
return ev;
};
const select = (word: string) => {
const text = editor().querySelector("p")!.firstChild!;
const i = text.textContent!.indexOf(word);
const r = document.createRange();
r.setStart(text, i);
r.setEnd(text, i + word.length);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(r);
};
it("turns selected words into the pasted link", async () => {
select("docs");
const ev = await paste({ text: "https://example.com/docs" });
expect(ev.defaultPrevented).toBe(true);
expect(exec).toHaveBeenCalledWith("createLink", false, "https://example.com/docs");
});
it("pastes a link as text when nothing is selected", async () => {
window.getSelection()!.removeAllRanges();
await paste({ text: "https://example.com/docs" });
expect(exec).not.toHaveBeenCalledWith("createLink", expect.anything(), expect.anything());
});
it("attaches a pasted image too large to sit in the text", async () => {
const big = new File([new Uint8Array(8)], "huge.png", { type: "image/png" });
Object.defineProperty(big, "size", { value: INLINE_IMAGE_MAX + 1 });
await paste({ files: [big] });
expect(onFiles).toHaveBeenCalledWith([big]);
});
it("keeps a normal-sized pasted image in the text", async () => {
const small = new File([new Uint8Array(8)], "shot.png", { type: "image/png" });
await paste({ files: [small] });
expect(onFiles).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RecipientInput } from "../RecipientInput";
import { useContacts } from "@/store/contacts";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/*
* Someone already in Cc was still offered when typing in To, so it was easy
* to address the same person twice from two fields.
*/
describe("suggestions across To, Cc and Bcc", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
useContacts.setState({
suggest: (async () => [
{ name: "Ann Lee", email: "[email protected]", source: "contact" },
{ name: "Ann Taylor", email: "[email protected]", source: "contact" },
]) as never,
});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.useRealTimers();
});
it("leaves out an address already in another recipient field", async () => {
await act(async () => {
root.render(<RecipientInput value={[]} exclude={[{ name: null, email: "[email protected]" }]} onChange={() => {}} />);
});
const input = host.querySelector("input")!;
await act(async () => {
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
set.call(input, "ann");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
vi.advanceTimersByTime(200);
await Promise.resolve();
});
const offered = host.textContent ?? "";
expect(offered).toContain("[email protected]");
expect(offered).not.toContain("[email protected]");
});
});
+1 -1
View File
@@ -438,7 +438,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
>
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
</span>
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
<span className="folder-icon" data-role={m.role ?? (scheduled ? "scheduled" : undefined)} style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
<span className="nav-label">{label}</span>
{count > 0 && <span className="nav-count" title={hiddenUnread ? t("{here} here, {inSubfolders} in subfolders", { here: own, inSubfolders: hiddenUnread }) : undefined}>{count > 9999 ? "9999+" : count}</span>}
{count > 0 && <span className="nav-dot" />}
@@ -0,0 +1,54 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MailboxTree } from "../MailboxTree";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
import type { Mailbox, MailboxRole } from "@/jmap/types";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
/*
* Role folders get their own icon tint, read from the icon's data-role; a
* plain folder has none, and a color the reader chose still applies.
*/
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
const box = (id: string, name: string, role: MailboxRole, unread = 0): Mailbox => ({
id, name, parentId: null, role, sortOrder: 0, totalEmails: unread, unreadEmails: unread, totalThreads: unread, unreadThreads: unread, myRights: rights, isSubscribed: true,
});
describe("folder icons by role", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
window.history.replaceState({}, "", "/mail/inbox");
useMail.setState({
mailboxes: { inbox: box("inbox", "Inbox", "inbox", 3), junk: box("junk", "Junk", "junk", 1), work: box("work", "Work", null, 2) },
mailboxesLoaded: true,
});
useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false, folderColors: { junk: "#123456" } } }));
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
act(() => root.render(<MailboxTree />));
});
afterEach(() => { act(() => root.unmount()); host.remove(); });
const icons = () => Array.from(document.querySelectorAll<HTMLElement>(".nav-item.folder-row .folder-icon"));
const byRole = (role: string) => icons().find((i) => i.dataset.role === role)!;
it("marks role folders with their role and leaves a plain folder unmarked", () => {
expect(icons().map((i) => i.dataset.role ?? null).sort()).toEqual(["inbox", "junk", null].sort());
});
it("keeps the reader's own color on a role folder", () => {
expect(byRole("junk").style.getPropertyValue("--folder-color")).toBe("#123456");
});
it("shows unread counts as pills", () => {
const pill = Array.from(document.querySelectorAll(".nav-item.folder-row.unread .nav-count")).map((p) => p.textContent);
expect(pill).toEqual(expect.arrayContaining(["3", "1", "2"]));
});
});
@@ -113,6 +113,14 @@ export function CalendarSettings() {
hint={t("A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar\u2019s own sidebar without turning it off here.")}
/>
<h2>{t("Contacts")}</h2>
<Switch
checked={s.collectRecipients}
onChange={(v) => update({ collectRecipients: v })}
label={t("Save people you write to as contacts")}
hint={t("Everyone you send to who isn\u2019t a contact yet is added to the Collected address book, so they\u2019re suggested on every device. Your own addresses are never added.")}
/>
<h2>{t("Working hours")}</h2>
<div className="field-row">
<div className="field">