Compare commits

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

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

Reported in coffey-labs/ihasmail#35.

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

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

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

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

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

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

Announcing and releasing stay on Gitea whatever BUILD_ON says.

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

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

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

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

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

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

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

3 new strings in all nine catalogs, unreviewed.

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

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

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

Closes #1

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

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

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

No new strings.

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

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

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

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

No new strings.

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

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

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

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

No new strings.

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

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

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

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

No new strings.

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

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

No new strings.

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

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

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

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

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

Fixes #407

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Code identifiers, capability URNs, env var names and comments are untouched.
2026-09-22 15:13:53 -07:00
116 changed files with 6682 additions and 997 deletions

No files matched your search

+1 -1
View File
@@ -75,7 +75,7 @@ APP_NAME=ihasmail
# you have patched it, point this at your own tree. Shown on the sign-in page # you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so # and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so
# the default is this fork. # the default is this fork.
SOURCE_URL=https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa SOURCE_URL=https://git.coffeylabs.org/inbuxa/inbuxa-webmail
# ---- Settings this installation decides (all optional) ---- # ---- Settings this installation decides (all optional) ----
# #
+26
View File
@@ -0,0 +1,26 @@
# Announce each published release on the community forum, in this project's
# Announcements category (coffey-labs/actions discourse-release; the repo ->
# category map is its release-map.json). Safe to re-run: one topic per tag.
# Run it by hand with a tag to announce a release whose own run failed: a
# release event runs the workflow as it was at the tag, so a fix on main only
# reaches an old release this way.
name: announce
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: release tag to announce, e.g. inbuxa-v2026.10.5-g17a8093
required: true
jobs:
announce:
runs-on: light
steps:
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
with:
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
tag: ${{ inputs.tag }}
+73 -5
View File
@@ -1,6 +1,19 @@
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off # CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once # GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
# this directory exists; .github/workflows stays as it was for GitHub. # this directory exists; .github/workflows is the GitHub side, below.
#
# WHERE THE BUILD RUNS. Gitea push-mirrors this repository to GitHub, and the
# org variable BUILD_ON picks which forge does the heavy work:
# * unset (or anything but `github`): every job here runs, as it always did,
# and GitHub's workflow skips all of its jobs.
# * `github`: the test, build and publish jobs here are skipped, GitHub
# Actions runs .github/workflows/ci.yml on its hosted runners (native
# arm64, no QEMU), and the `github` job below waits for the commit status
# that run posts back, passing or failing with it. So this run's result is
# still the one that counts, for a PR's checks as for anything that merges
# on green CI. The variable is set on both forges, and must agree.
# If GitHub is ever unavailable, unsetting BUILD_ON here is the whole
# fallback: the jobs below take over again unchanged.
# #
# Releases are cut by pushing a tag named `inbuxa-v<version>`, where # Releases are cut by pushing a tag named `inbuxa-v<version>`, where
# <version> is what scripts/version.mjs says for the tagged commit with the # <version> is what scripts/version.mjs says for the tagged commit with the
@@ -35,6 +48,7 @@ concurrency:
jobs: jobs:
# -------------------------------------------------------------- test ------ # -------------------------------------------------------------- test ------
node: node:
if: ${{ vars.BUILD_ON != 'github' }}
runs-on: light runs-on: light
container: container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
@@ -73,7 +87,7 @@ jobs:
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. The # equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. The
# Dockerfile builds everything itself; `needs` only keeps the order. # Dockerfile builds everything itself; `needs` only keeps the order.
docker-build: docker-build:
if: ${{ !startsWith(github.ref, 'refs/tags/') }} if: ${{ vars.BUILD_ON != 'github' && !startsWith(github.ref, 'refs/tags/') }}
needs: [node] needs: [node]
runs-on: docker runs-on: docker
container: container:
@@ -93,7 +107,7 @@ jobs:
# all agree, and the commit has to be on main, so a release never describes # all agree, and the commit has to be on main, so a release never describes
# code that was not reviewed onto the default branch. # code that was not reviewed onto the default branch.
version: version:
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }} if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
runs-on: light runs-on: light
container: container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
@@ -119,13 +133,13 @@ jobs:
echo "DOCKER_TAG=${V/+/-}" >> "$GITHUB_OUTPUT" echo "DOCKER_TAG=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "VERSION=$V DOCKER_TAG=${V/+/-}" echo "VERSION=$V DOCKER_TAG=${V/+/-}"
# Multi-arch image at <REGISTRY>/inbuxa/ihasmail-inbuxa, then the release. # Multi-arch image at <REGISTRY>/inbuxa/inbuxa-webmail, then the release.
# arm64 is built under QEMU on this amd64 host, which is slow but fine for # arm64 is built under QEMU on this amd64 host, which is slow but fine for
# a hand-cut release. PACKAGE_TOKEN (jcoffey-dev, write:package) logs in: # a hand-cut release. PACKAGE_TOKEN (jcoffey-dev, write:package) logs in:
# the job's own token is refused by the container registry. The release is # the job's own token is refused by the container registry. The release is
# created last, so a release on the page always has its image behind it. # created last, so a release on the page always has its image behind it.
publish: publish:
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }} if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
needs: [node, version] needs: [node, version]
runs-on: docker runs-on: docker
container: container:
@@ -177,3 +191,57 @@ jobs:
echo "release $TAG created" echo "release $TAG created"
- if: always() - if: always()
run: docker logout "$REGISTRY" || true run: docker logout "$REGISTRY" || true
# The release above is made with the job's own token, and Gitea starts no
# workflow for events the Actions bot causes -- announce.yml's
# 'on: release' never fires for it -- so announce it from here. With
# BUILD_ON=github the release is created by GitHub's run instead, and this
# follows the `github` job. Announcing stays on Gitea either way; the
# action posts once per tag, so a second attempt is a no-op.
announce:
needs: [publish, github]
if: ${{ always() && startsWith(github.ref, 'refs/tags/inbuxa-v') && (needs.publish.result == 'success' || needs.github.result == 'success') }}
runs-on: light
steps:
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
with:
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
tag: ${{ github.ref_name }}
# ------------------------------------------------------------ github ------
# With BUILD_ON=github, the work above happens in GitHub Actions, which
# posts one commit status back here when it finishes: "github/ci (branch)"
# for a branch push, "github/ci (tag)" for a tag. This job waits for that
# status on the commit under test -- the PR's head for a pull request -- and
# passes or fails with it. Nothing arriving within the timeout means GitHub
# never built the commit (a mirror that failed to sync, or GitHub being
# down): check the mirror, or unset BUILD_ON to build here.
github:
if: ${{ vars.BUILD_ON == 'github' }}
# Its own runner label with plenty of slots: this job only polls, but holds a slot
# for as long as the GitHub build takes, and must not starve the build runners.
runs-on: wait
timeout-minutes: 150
container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
CONTEXT: github/ci (${{ github.ref_type == 'tag' && format('tag {0}', github.ref_name) || 'branch' }})
steps:
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl jq >/dev/null
- shell: bash
run: |
set -uo pipefail
url="$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/commits/$SHA/statuses?limit=50"
echo "waiting for '$CONTEXT' on $SHA"
while :; do
state="$(curl -fsS -H "Authorization: token $TOKEN" "$url" \
| jq -r --arg c "$CONTEXT" '[.[] | select(.context == $c)] | sort_by(.id) | last | .status // empty')"
case "$state" in
success) echo "GitHub reported success"; exit 0 ;;
failure|error) echo "GitHub reported $state -- see the status's link for the run" >&2; exit 1 ;;
esac
sleep 20
done
-44
View File
@@ -1,44 +0,0 @@
version: 2
updates:
# The npm entry sits at the root because that is where the single lockfile
# is: root, server and web are one npm workspace, so one entry covers all
# three. Pointing entries at server/ or web/ would find package.json files
# with no lockfile beside them and update nothing.
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
open-pull-requests-limit: 5
groups:
# Everything routine arrives as one PR a week, so the dashboard is not
# the only place these get noticed. Majors are deliberately left out of
# the group: they are migrations, not bumps -- vitest 3 to 4 is one --
# and each deserves its own PR and its own CI run.
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
groups:
actions:
patterns:
- "*"
# The runtime and build stages both pin node:22-alpine, so this is what
# keeps the published container images off a stale base between the weekly
# releases.
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
+330 -24
View File
@@ -1,39 +1,345 @@
name: CI # CI and publishing on GitHub Actions, for a repository whose source of truth
# is the self-hosted Gitea. Gitea push-mirrors every commit and tag here, and
# this workflow does the heavy work on GitHub's hosted runners -- native arm64
# included -- then reports the result back to Gitea as a commit status.
#
# THE SWITCH. Every job here runs only when the org variable BUILD_ON is
# `github`. Gitea's .gitea/workflows/ci.yml reads the same variable (set on
# the Gitea org too): with it set, Gitea skips its own build jobs and waits for
# the status this workflow posts; without it, Gitea builds everything itself,
# exactly as before, and every job here is skipped. If GitHub is ever
# unavailable, unsetting BUILD_ON on Gitea is the whole fallback.
#
# There is no pull_request trigger: pull requests live on Gitea. A PR's branch
# arrives here as an ordinary push, and the status lands on its head commit,
# which is where Gitea's PR looks for it.
#
# Releases are cut by pushing a tag named `inbuxa-v<version>` (see
# .gitea/workflows/ci.yml for why the prefix matters: this repository carries
# upstream ihasmail's own `v...` tags, and only `inbuxa-v` tags publish).
#
# Org configuration, not in this file:
# vars.BUILD_ON `github` to build here
# vars.REGISTRY the Gitea container registry's DNS-only name
# vars.GITEA_URL Gitea's public URL, for statuses, releases and packages
# secrets.GITEA_TOKEN jcoffey-dev, write:repository + write:package
#
# Every `uses:` is pinned to a full commit SHA with the release in the
# trailing comment. A tag is a mutable pointer, so trusting `@v7` is trusting
# every future version of that action. Do not "simplify" a pin back to a tag.
name: ci
on: on:
push: push:
branches: [main] branches: ['**']
pull_request: tags: ['**']
# Lets CI be run by hand against any ref, including a specific commit.
# Without this there is no way to re-run a check that never started: a run
# GitHub queues and then orphans -- as it did to every run created during the
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
# nor canceled ("already completed"), and the workflow has no other trigger
# to reach for. Useful too for putting a check on a commit that predates a CI
# change, without pushing an empty commit to move it.
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GITEA_URL: ${{ vars.GITEA_URL }}
REGISTRY: ${{ vars.REGISTRY }}
# A registry path must be lowercase; the repository name already is.
IMAGE: ${{ vars.REGISTRY }}/inbuxa/inbuxa-webmail
# A tag's context names the tag: upstream v* and inbuxa-v* tags can sit on
# the same commit, and Gitea must not read one tag's result as the other's.
STATUS_CONTEXT: github/ci (${{ github.ref_type == 'tag' && format('tag {0}', github.ref_name) || 'branch' }})
jobs: jobs:
build: # Tells Gitea a result is on its way, so a PR shows the check as running
# rather than missing.
pending:
if: ${{ vars.BUILD_ON == 'github' }}
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
jq -n --arg c "$STATUS_CONTEXT" \
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' \
| curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" --data @- \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
# -------------------------------------------------------------- test ------
# version.test.ts shells out to git to resolve a build version from the
# history, so the checkout is a full one. The hosted runner runs as an
# unprivileged user, so config.test.ts's read-only directory holds here
# without the `su node` Gitea needs.
node:
if: ${{ vars.BUILD_ON == 'github' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Every `uses:` in this repository is pinned to a full commit SHA, with
# the release it belongs to in the trailing comment, and the repository
# requires it -- an unpinned ref fails the run rather than quietly
# resolving. A tag is a mutable pointer: `@v7` is whatever the publisher
# last moved it to, so trusting one is trusting every future version of
# that action, including the one pushed by whoever compromises the
# account. Read the comment for the version; the SHA is what runs.
#
# Dependabot updates both halves together on its weekly github-actions
# run, so this costs nothing to keep current -- do not "simplify" a pin
# back to a tag.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: 26 node-version: 26
cache: npm cache: npm
# --ignore-scripts: a postinstall script in any transitive dependency
# would otherwise run with the job's credentials in its environment.
- run: npm ci --ignore-scripts - run: npm ci --ignore-scripts
- run: npm run typecheck - run: npm run typecheck
- run: npm test - run: npm test
- run: npm run build - run: npm run build
- name: Docker build
run: docker build -t ihasmail:ci . # ------------------------------------------------------------- build ------
# Proves the Dockerfile still builds on every change, without pushing.
docker-build:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
needs: [node]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: docker build -t "ihasmail:ci-${GITHUB_SHA::8}" .
# ----------------------------------------------------------- release ------
# Only for `inbuxa-v` tags. The tag has to name its own commit's version, so
# the image, the release and the About screen all agree, and the commit has
# to be on main, so a release never describes code that was not reviewed
# onto the default branch.
version:
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
docker_tag: ${{ steps.v.outputs.docker_tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: v
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
V="$(node scripts/version.mjs)"
want="inbuxa-v${V/+/-}"
[ "$TAG" = "$want" ] || { echo "::error::$TAG does not name this commit's version; expected $want"; exit 1; }
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|| { echo "::error::$TAG is not on main"; exit 1; }
echo "version=$V" >> "$GITHUB_OUTPUT"
# A Docker tag may not contain '+', so build metadata becomes '-'.
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "version $V -> tag ${V/+/-}"
# Each architecture on its own native runner, pushed as an untagged image by
# digest; `publish` joins the two digests into one multi-arch tag.
build:
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
needs: [node, version]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- name: Build and push by digest
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
# Attestations add manifests of their own to the index, and
# `imagetools create` below expects the two entries pushed here.
provenance: false
sbom: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Save the digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
mkdir -p /tmp/digests
# Bare hash as the filename; the prefix is put back when joining.
touch "/tmp/digests/${DIGEST#sha256:}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
# Kept a week, and overwritable, so a re-run of the build or of
# publish alone still finds (or replaces) the digests.
retention-days: 7
overwrite: true
if-no-files-found: error
# Joins the digests into `:<version>` and `:latest`, links the package to
# the repository on Gitea, then creates the release there -- last, so a
# release on the page always has its image behind it. The release is made
# with GITEA_TOKEN, a user's token, so Gitea's announce.yml fires for it;
# Gitea's ci.yml announces as well once this run's status arrives, and the
# announce action posts once per tag whichever gets there first.
publish:
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
needs: [version, build]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- name: Create the manifest
env:
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
run: |
refs=()
for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")"); done
docker buildx imagetools create -t "${IMAGE}:${DOCKER_TAG}" -t "${IMAGE}:latest" "${refs[@]}"
docker buildx imagetools inspect "${IMAGE}:${DOCKER_TAG}"
# Shows the package on the repository's Packages tab. Idempotent.
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/packages/inbuxa/container/inbuxa-webmail/-/link/inbuxa-webmail" \
|| echo "package already linked (or link refused); not fatal"
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }}
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
run: |
set -eu
API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
# A re-run finds the release already there.
if curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" "$API/tags/$TAG"; then
echo "release $TAG already exists"; exit 0
fi
body="$(printf 'INBUXA webmail %s.\n\nImage: `%s:%s` (linux/amd64, linux/arm64), also tagged `latest`.' "$VERSION" "$IMAGE" "$DOCKER_TAG")"
jq -n --arg tag "$TAG" --arg body "$body" '{tag_name:$tag, name:$tag, body:$body}' \
| curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
--data @- "$API"
echo "release $TAG created"
# ------------------------------------------------------ ghcr replica ------
# Copies the release image from the Gitea registry, which stays the
# authoritative one, to ghcr.io under the same version tag and :latest. It is
# a copy, not a second build: the digest on GHCR is the digest on the
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
# out of the report to Gitea, like the release copy, so a GHCR problem
# cannot fail a release.
ghcr:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
needs: [version, publish]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.version.outputs.docker_tag }}
run: |
set -euo pipefail
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
tag="$TAG"
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag"
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
echo "registry $src:$tag = $want"
echo "ghcr $dst:$tag = $got"
[ "$want" = "$got" ] || echo "::warning::GHCR digest differs from the registry's"
docker logout ghcr.io
# ---------------------------------------------------- github release ------
# Copies this tag's Gitea release -- notes and files -- to a GitHub release,
# so the replica's Releases page, and anyone watching it, keeps up. Gitea's
# release is the real one; this is left out of the report to Gitea, so a
# failure here cannot fail a release. PR and issue numbers in the notes are
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
github-release:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
needs: [publish]
runs-on: ubuntu-latest
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
steps:
- run: |
set -euo pipefail
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "GitHub already has a release for $TAG"; exit 0
fi
# The Gitea release exists by now if this run made it; if the weekly
# release job made it, it came before the tag. Allow a few minutes.
code=0
for _ in $(seq 1 15); do
code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")"
[ "$code" = 200 ] && break
sleep 20
done
if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi
if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi
export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)"
jq -r '.body // ""' rel.json | perl -pe 's{(?<![\w/&\[])#(\d+)\b}{[#$1]($ENV{BASE}/pulls/$1)}g' > notes.md
printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \
"$(jq -r .html_url rel.json)" "$BASE" >> notes.md
files=()
mkdir -p files
while IFS=$'\t' read -r name url; do
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
--notes-file notes.md "$kind" "${files[@]}"
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
# One commit status on Gitea for the whole run: what Gitea's ci.yml waits
# for, and what a Gitea PR shows.
report:
if: ${{ always() && vars.BUILD_ON == 'github' }}
needs: [pending, node, docker-build, version, build, publish]
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }}
run: |
# A cancelled run was superseded by a newer run for the same commit (the
# mirror can push one commit twice); that run reports. Posting "failure"
# here would fail the Gitea check while the real build is still going.
if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi
jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' \
| curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" --data @- \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
echo "reported $STATE as '$STATUS_CONTEXT'"
-69
View File
@@ -1,69 +0,0 @@
# Prune old image versions from GHCR.
#
# Releases are kept forever -- they carry no assets and their generated notes
# are this project's only changelog, so deleting one destroys history that
# cannot be reconstructed for nothing saved. Images are the opposite: a
# multi-arch build a week, and the by-digest push in publish.yml leaves two
# untagged per-architecture manifests behind each time on top of the tagged
# index. Those accumulate and nobody wants fifty of them.
#
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
# `delete-only-untagged-versions` -- will happily delete the per-architecture
# manifests that a multi-arch tag points *at*, because they are untagged by
# design. Nothing appears to break: the tag still exists, and pulls simply
# start failing for one architecture. This action understands manifest lists
# and will not orphan a retained index, and `validate` re-checks every
# multi-arch manifest against the registry afterwards.
#
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
# exactly what would be deleted without rebuilding and re-pushing an image to
# find out.
name: Prune images
on:
workflow_call:
inputs:
dry_run:
type: boolean
default: false
workflow_dispatch:
inputs:
dry_run:
description: "List what would be deleted, delete nothing"
type: boolean
default: true
jobs:
prune:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
# The only third-party action here that is not published by GitHub or
# Docker, and the one with the most to lose: it is handed
# `packages: write` and its whole job is deletion, so a ref repointed at
# something else -- by a compromise or a mistake upstream -- is a bad
# day. It was pinned to a commit long before the rest of them were.
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
with:
owner: Coffey-Labs
package: ihasmail
token: ${{ secrets.GITHUB_TOKEN }}
# Ten weekly releases is roughly a quarter of history, which is more
# than enough to roll back to and far less than the year's worth that
# would otherwise pile up. Older *releases* stay either way; this
# only removes the images.
keep-n-tagged: 10
# Belt and braces on top of the action's own manifest awareness:
# `latest` is never a candidate for deletion under any counting.
exclude-tags: latest
delete-untagged: true
# Sweeps the wreckage of a half-failed run: an index whose platform
# images did not all land, and referrers whose parent is gone.
delete-partial-images: true
delete-orphaned-images: true
# Checks every remaining multi-architecture manifest still resolves
# in the registry. This is the step that would catch the footgun
# above rather than leaving a reader to discover it on `docker pull`.
validate: true
dry-run: ${{ inputs.dry_run }}
-206
View File
@@ -1,206 +0,0 @@
# Publish the container image to GHCR.
#
# The README and the docs site have told people to run
# `ghcr.io/coffey-labs/ihasmail:latest` for a long time, and nothing ever
# pushed it: `docker pull` answered `denied`, because the package did not
# exist. This is the workflow that makes those instructions true. It is also
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
# both install by pulling an image and neither builds from source.
#
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
# a public repository, and an anonymous `docker pull` will still answer
# `denied`. Nothing in a workflow can change that -- the visibility is set once
# by hand under the package's settings, and until it is, this looks like it
# worked while the docs stay just as wrong as before. Check with a logged-out
# pull, not with one from a machine that has credentials.
#
# Two architectures, each built on its own native runner rather than under
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
# instruction translation, which takes tens of minutes and occasionally runs
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
# the same work at native speed. The cost is the by-digest dance below: each
# runner pushes an untagged image, and a final job joins the two digests into
# one multi-arch tag.
name: Publish image
on:
release:
types: [published]
# Callable, so release.yml can build the release it just cut. This is not a
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
# `release` event -- GitHub refuses to let a token trigger another workflow,
# to stop a workflow looping on its own output. A scheduled job that cut a
# release and expected this file to notice would silently never publish. The
# alternatives are a personal access token kept as a secret, or calling the
# workflow directly. This is the one that needs no credential.
workflow_call:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
type: string
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
# orphans can be neither rerun nor canceled, and this workflow otherwise
# only fires on a release -- which is not something to cut twice because a
# runner died. `ref` also allows publishing an image for a tag that predates
# this workflow, which is how the first one gets built.
workflow_dispatch:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
default: main
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
env:
# Hardcoded rather than derived from github.repository: a registry path must
# be lowercase and the owner is spelled `Coffey-Labs`, so deriving it means
# remembering to lowercase it. This is the string the docs already name.
# inbuxa: this fork publishes to INBUXA's own path. Inherited from public
# ihasmail, which publishes ghcr.io/coffey-labs/ihasmail -- leaving that
# here would push INBUXA's webmail over the image every public ihasmail
# install pulls, which SPEC.md 5 exists to prevent.
IMAGE: ghcr.io/inbuxa/ihasmail-inbuxa
jobs:
# The version is worked out once and handed to both builds, so the two
# architectures cannot disagree about what they are. scripts/version.mjs
# reads the commit date and how the commit arrived, so it needs real history
# rather than a shallow clone.
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
docker_tag: ${{ steps.v.outputs.docker_tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: v
run: |
V="$(node scripts/version.mjs)"
echo "version=$V" >> "$GITHUB_OUTPUT"
# A Docker tag may not contain '+', so build metadata becomes '-'.
# The build is still *told* the real form, which is what About and
# /api/health report.
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "version $V -> tag ${V/+/-}"
build:
needs: version
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
# Attestations are off deliberately: they add manifests of their own
# to the index, and `imagetools create` below expects the two entries
# it pushed rather than four.
provenance: false
sbom: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Save the digest
run: |
mkdir -p /tmp/digests
# The prefix is stripped here and put back in the merge job, so the
# filename is the bare hash. Leaving it on produces
# `image@sha256:sha256:...` when the reference is rebuilt.
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# One artifact per platform; the merge job globs them back together.
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
retention-days: 1
if-no-files-found: error
# Joins the per-architecture digests into a single tagged manifest, so
# `docker pull ghcr.io/coffey-labs/ihasmail:<tag>` resolves on both.
publish:
needs: [version, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create the manifest
run: |
# Arrays rather than a string: the tags and the digest references
# have to reach docker as separate arguments, and building them by
# word-splitting an unquoted variable is the version of this that
# breaks the day a value contains a space.
tags=(-t "${IMAGE}:${{ needs.version.outputs.docker_tag }}")
# :latest follows real releases only. A prerelease that moved it
# would hand every `:latest` deployment an unfinished build, and a
# dispatch run has to ask for it on purpose.
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
tags+=(-t "${IMAGE}:latest")
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
tags+=(-t "${IMAGE}:latest")
fi
refs=()
for f in /tmp/digests/*; do
refs+=("${IMAGE}@sha256:$(basename "$f")")
done
echo "tags: ${tags[*]}"
echo "refs: ${refs[*]}"
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
- name: Show what landed
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.docker_tag }}"
# Runs only after a successful publish, because that is the only moment the
# package grows. See cleanup.yml for why this is not the obvious one-liner.
prune:
needs: publish
permissions:
packages: write
uses: ./.github/workflows/cleanup.yml
-163
View File
@@ -1,163 +0,0 @@
# Cut a release once a week, but only if there is something in it.
#
# Releases had drifted 184 commits behind main, which made `:latest` describe
# a build nobody was running -- the demo, prod and anyone building from source
# were all ahead of it. Publishing on release is the right trigger only if
# releases actually happen, so this is the part that makes that true without
# anyone having to remember.
#
# It does nothing on a quiet week. A release with no commits in it is worse
# than no release: it moves `:latest` to an identical build, spends a version
# number, and mails everybody watching the repository about nothing.
name: Weekly release
on:
schedule:
# Mondays, 09:17 UTC. GitHub runs scheduled jobs on a best-effort basis and
# can delay a run by a good while when the queue is busy, so do not read
# the exact minute as a promise. The odd minute is deliberate: the top of
# the hour is when most schedules fire, and at 09:00 the first scheduled
# run started almost six hours late and the second had not started at all
# four and a half hours in. Moving off the hour does not make GitHub keep
# time, but it stops competing for the busiest slot. A missed week can be
# cut by hand with workflow_dispatch; a late scheduled run that follows
# finds the tag already there and does nothing.
#
# Note also that GitHub disables scheduled workflows in a repository with
# no activity for 60 days -- not a concern while this one is being worked
# on weekly, but it is why a silent stop is worth checking for before
# assuming the file is broken.
- cron: "17 9 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Work out what would be released, then stop"
type: boolean
default: false
# One at a time. Two overlapping runs would race to create the same tag, and
# the loser fails noisily for a reason that has nothing to do with the code.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
tag: ${{ steps.decide.outputs.tag }}
title: ${{ steps.decide.outputs.title }}
sha: ${{ steps.decide.outputs.sha }}
previous: ${{ steps.decide.outputs.previous }}
count: ${{ steps.decide.outputs.count }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
- id: decide
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# The newest published release, or empty on a repository that has
# never had one -- in which case everything counts as new. Drafts are
# excluded: an unpublished draft is not a release anybody has, so
# counting from it would hide commits that have never shipped.
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
# A tag named by a release is normally present after a full checkout,
# but a release can outlive its tag. Falling back to the whole
# history is the safe direction to be wrong in: it over-counts, which
# cuts a release that was due anyway, where under-counting would skip
# one that was.
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
count="$(git rev-list --count "${previous}..HEAD")"
else
count="$(git rev-list --count HEAD)"
fi
version="$(node scripts/version.mjs)"
# A Docker tag may not contain '+', and neither should the git tag,
# so the two always agree about what to call a build.
tag="v${version/+/-}"
title="v${version%%+*}"
sha="$(git rev-parse HEAD)"
should_release=true
reason=""
if [ "$count" -eq 0 ]; then
should_release=false
reason="no commits since ${previous}"
elif git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
# Same commit, different week: the version is derived from the
# commit, so nothing new means the tag already exists.
should_release=false
reason="tag ${tag} already exists"
fi
{
echo "should_release=$should_release"
echo "tag=$tag"
echo "title=$title"
echo "sha=$sha"
echo "previous=$previous"
echo "count=$count"
} >> "$GITHUB_OUTPUT"
# Written to the run summary so a skipped week reads as a decision
# rather than as a workflow that quietly did nothing.
{
echo "### Weekly release"
echo
if [ "$should_release" = "true" ]; then
echo "Releasing **${tag}** — ${count} commit(s) since ${previous:-the beginning}."
else
echo "Nothing to release: ${reason}."
fi
} >> "$GITHUB_STEP_SUMMARY"
cut:
needs: check
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
args=(--target "${{ needs.check.outputs.sha }}"
--title "${{ needs.check.outputs.title }}"
--generate-notes)
# Bound the notes to what is actually new. Without a start tag the
# generator reaches back to whatever it decides is previous, which on
# a repository with older tag shapes is not always the last release.
if [ -n "${{ needs.check.outputs.previous }}" ]; then
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
fi
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
# Called rather than left to the `release` trigger on purpose: see the note
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
# event, so without this the tag would exist and no image would follow it.
publish:
needs: [check, cut]
permissions:
contents: read
packages: write
uses: ./.github/workflows/publish.yml
with:
ref: ${{ needs.check.outputs.sha }}
tag_latest: true
+8 -2
View File
@@ -8,6 +8,12 @@
<a href="LICENSE"><img alt="License: AGPL-3.0-or-later" src="https://img.shields.io/badge/license-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a> <a href="LICENSE"><img alt="License: AGPL-3.0-or-later" src="https://img.shields.io/badge/license-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
</p> </p>
> [!NOTE]
> Development happens on [git.coffeylabs.org/inbuxa/inbuxa-webmail](https://git.coffeylabs.org/inbuxa/inbuxa-webmail); the copy on GitHub is a read-only mirror.
> Report issues at **[git.coffeylabs.org/inbuxa/inbuxa-webmail/issues](https://git.coffeylabs.org/inbuxa/inbuxa-webmail/issues)**, and join discussions at **[community.coffeylabs.org](https://community.coffeylabs.org)**.
>
> This repository was called `ihasmail-inbuxa` until October 2026. Container images are now published as `inbuxa/inbuxa-webmail`; the old `inbuxa/ihasmail-inbuxa` image stops at `2026.9.26-g654d298`.
The webmail of the INBUXA suite: mail, calendars, contacts, files and filters The webmail of the INBUXA suite: mail, calendars, contacts, files and filters
in one app that works as well on a phone as on a desktop. It talks only JMAP to in one app that works as well on a phone as on a desktop. It talks only JMAP to
the INBUXA mail server, and keeps nothing of its own: everything durable, the INBUXA mail server, and keeps nothing of its own: everything durable,
@@ -29,7 +35,7 @@ settings included, lives on the server, so the container is disposable.
Everything else is in INBUXA Admin. Everything else is in INBUXA Admin.
- **Sign-in on the mail server's own page**, two-factor included. The webmail - **Sign-in on the mail server's own page**, two-factor included. The webmail
never handles a password to sign someone in, and holds only sealed tokens. never handles a password to sign someone in, and holds only sealed tokens.
- **Ten interface languages and twelve themes.** - **Eleven interface languages and twelve themes.**
## Configuration ## Configuration
@@ -70,7 +76,7 @@ docker compose up --build -d
## Source code ## Source code
INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork: INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork:
<https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa>. The sign-in page and Settings › <https://git.coffeylabs.org/inbuxa/inbuxa-webmail>. The sign-in page and Settings ›
About link there, beside the version, which names the commit the running build About link there, beside the version, which names the commit the running build
came from. came from.
+1 -1
View File
@@ -29,7 +29,7 @@ services:
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)} APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail} APP_NAME: ${APP_NAME:-ihasmail}
BASE_PATH: ${BASE_PATH:-} BASE_PATH: ${BASE_PATH:-}
SOURCE_URL: ${SOURCE_URL:-https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa} SOURCE_URL: ${SOURCE_URL:-https://git.coffeylabs.org/inbuxa/inbuxa-webmail}
TRUST_PROXY: "1" TRUST_PROXY: "1"
IMAGE_PROXY: "1" IMAGE_PROXY: "1"
volumes: volumes:
+3 -3
View File
@@ -47,7 +47,7 @@ after(() => {
}); });
/** /**
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the * Stalwart advertises `urn:inbuxa:jmap:registry` only per-account, never in the
* session-level capabilities. Looking for it at the top level alone reported * session-level capabilities. Looking for it at the top level alone reported
* every real 0.16 server as older than 0.16 — and now that the same check * every real 0.16 server as older than 0.16 — and now that the same check
* decides whether a sign-in is allowed at all, that mistake would lock * decides whether a sign-in is allowed at all, that mistake would lock
@@ -57,8 +57,8 @@ test("the session is accepted on a server that advertises the registry per-accou
const res = await call("/api/auth/session"); const res = await call("/api/auth/session");
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(res.body.ihasmail.server.edition, "oss"); assert.equal(res.body.ihasmail.server.edition, "oss");
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look"); assert.equal(res.body.capabilities["urn:inbuxa:jmap:registry"], undefined, "not where a client would first look");
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server"); assert.ok("urn:inbuxa:jmap:registry" in res.body.primaryAccounts, "but here, as on a real server");
}); });
test("the registry reports an account with nothing set up yet", async () => { test("the registry reports an account with nothing set up yet", async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.
* the registry is known to be there. * the registry is known to be there.
*/ */
const STALWART_CAP = "urn:stalwart:jmap"; const STALWART_CAP = "urn:inbuxa:jmap:registry";
const JMAP_CORE = "urn:ietf:params:jmap:core"; const JMAP_CORE = "urn:ietf:params:jmap:core";
/** Stalwart's id for a singleton object; the number it encodes spells this. */ /** Stalwart's id for a singleton object; the number it encodes spells this. */
const SINGLETON = "singleton"; const SINGLETON = "singleton";
+3 -3
View File
@@ -44,7 +44,7 @@ test("locales that carry no language are dropped, not passed through", () => {
test("a server without the registry is not asked for anything", async () => { test("a server without the registry is not asked for anything", async () => {
// Sign-in refuses these, so getAccountInfo should never reach the wire for // Sign-in refuses these, so getAccountInfo should never reach the wire for
// one - and must not, since a server that cannot parse `urn:stalwart:jmap` // one - and must not, since a server that cannot parse `urn:inbuxa:jmap:registry`
// fails the whole request rather than the one call. // fails the whole request rather than the one call.
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} }; const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
const info = await getAccountInfo("session-unsupported", "Basic x", session as never); const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
@@ -57,7 +57,7 @@ test("no capabilities at all is treated the same way", async () => {
}); });
/** /**
* Where Stalwart actually advertises `urn:stalwart:jmap`. * Where Stalwart actually advertises `urn:inbuxa:jmap:registry`.
* *
* Not in the session-level `capabilities`: `Session::new` builds those from a * Not in the session-level `capabilities`: `Session::new` builds those from a
* fixed list that has never carried this capability, in any 0.16.x. It is * fixed list that has never carried this capability, in any 0.16.x. It is
@@ -70,7 +70,7 @@ test("no capabilities at all is treated the same way", async () => {
* This check now decides whether a sign-in is allowed at all, so getting it * This check now decides whether a sign-in is allowed at all, so getting it
* wrong would lock every user out of a perfectly good server. * wrong would lock every user out of a perfectly good server.
*/ */
const STALWART = "urn:stalwart:jmap"; const STALWART = "urn:inbuxa:jmap:registry";
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }; const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => { test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
+11 -5
View File
@@ -41,9 +41,10 @@ import {
revokeAppPassword, revokeAppPassword,
} from "./account.js"; } from "./account.js";
import { imageProxyHandler } from "./imageproxy.js"; import { imageProxyHandler } from "./imageproxy.js";
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js"; import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, passwordConfirms, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
import { icsProxyHandler } from "./icsproxy.js"; import { icsProxyHandler } from "./icsproxy.js";
import { staticHandler } from "./static.js"; import { staticHandler } from "./static.js";
import { mailNode, webmailNode } from "./nodes.js";
type Env = { Variables: { session: LiveSession } }; type Env = { Variables: { session: LiveSession } };
@@ -609,6 +610,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
return c.json({ ok: true }); return c.json({ ok: true });
}); });
/* ihasmail-inbuxa: which webmail node this is, and which mail node it talks to (nodes.ts). */
api.get("/about/nodes", requireSession, async (c) => {
const session = c.get("session");
return c.json({ webmail: webmailNode(), mailServer: await mailNode(upstreamFor(session.username)) });
});
api.get("/auth/sessions", requireSession, (c) => { api.get("/auth/sessions", requireSession, (c) => {
const session = c.get("session"); const session = c.get("session");
return c.json({ current: session.id, sessions: sessions.listForUser(session.account) }); return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
@@ -1120,11 +1127,10 @@ async function readJson<T>(c: Context): Promise<T | null> {
*/ */
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> { async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
if (session.tokens) { if (session.tokens) {
// Holding no password, the only judge is the server. // Holding no password, the only judge is the server, asked on its sign-in
// endpoint since it takes no password over JMAP.
try { try {
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`; return await passwordConfirms({ base: upstreamFor(session.username), username: session.username, password: candidate });
await fetchUpstreamSession(authorization, upstreamFor(session.username));
return true;
} catch { } catch {
return false; return false;
} }
+9 -3
View File
@@ -278,7 +278,7 @@ if (oauthClientSecret && !publicUrl) {
export const config = { export const config = {
isProd, isProd,
appName: env("APP_NAME", "INBUXA"), appName: env("APP_NAME", "inbuxa"),
settingsPolicy: readSettingsPolicy(), settingsPolicy: readSettingsPolicy(),
/** /**
* What this build calls itself: `2.16.57`. Set by the image build from * What this build calls itself: `2.16.57`. Set by the image build from
@@ -292,10 +292,16 @@ export const config = {
* *
* The AGPL asks whoever *runs* a modified version to offer that version's * The AGPL asks whoever *runs* a modified version to offer that version's
* source, not the one it was forked from -- so anyone deploying a patched * source, not the one it was forked from -- so anyone deploying a patched
* ihasmail should point this at their own tree. ihasmail-inbuxa is itself * ihasmail should point this at their own tree. inbuxa-webmail is itself
* such a tree, so the default is INBUXA's fork. * such a tree, so the default is INBUXA's fork.
*/ */
sourceUrl: env("SOURCE_URL", "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa"), sourceUrl: env("SOURCE_URL", "https://git.coffeylabs.org/inbuxa/inbuxa-webmail"),
/**
* ihasmail-inbuxa: this webmail's own name, shown in Settings > About next
* to the mail node it talks to. Set per host by the deploy; empty means the
* container's hostname. See nodes.ts.
*/
nodeName: env("NODE_NAME", ""),
host: env("HOST", "0.0.0.0"), host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080), port: int("PORT", 8080),
/** /**
+1 -1
View File
@@ -18,7 +18,7 @@ const PORT = 18799;
process.env.MOCK_PORT = String(PORT); process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]"; process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password"; process.env.MOCK_PASS = "demo-password";
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:inbuxa:jmap:registry
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`; process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-login-guard"; process.env.APP_SECRET = "test-secret-for-login-guard";
+1 -1
View File
@@ -5,7 +5,7 @@ export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../we
export const PORT = Number(process.env.MOCK_PORT ?? 8788); export const PORT = Number(process.env.MOCK_PORT ?? 8788);
/** /**
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested * Omit `urn:inbuxa:jmap:registry` from the session, so a sign-in can be tested
* against a server ihasmail does not support. This is only that: the rest of * against a server ihasmail does not support. This is only that: the rest of
* the mock still behaves like 0.16. Emulating 0.15 properly went with the * the mock still behaves like 0.16. Emulating 0.15 properly went with the
* support for it. * support for it.
+11 -6
View File
@@ -40,10 +40,10 @@ export function putBlob(data: Buffer | string, type: string): string {
export const people = [ export const people = [
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"], ["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"], ["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
["Stalwart Labs", "hello@stalw.art"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"], ["inbuxa", "hello@inbuxa.org"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
]; ];
export const subjects = [ export const subjects = [
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff", "Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to inbuxa!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
"Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?", "Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?",
"Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in", "Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in",
]; ];
@@ -122,7 +122,11 @@ export function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [
hasAttachment: false, hasAttachment: false,
preview: body.slice(0, 120), preview: body.slice(0, 120),
textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
htmlBody: [], // `htmlBody` is derived (RFC 8621 4.1.4): a message with no HTML
// alternative still gets one, holding the text/plain part. Checked against
// Stalwart 0.16.21 on 2026-09-10 -- see hasHtmlAlternative() in the client,
// which reads the part's type rather than trusting this list to be empty.
htmlBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
attachments: [], attachments: [],
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } }, bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
bodyStructure: { bodyStructure: {
@@ -169,8 +173,8 @@ export const STYLED_MARKETING_HTML = `<html><head><style>
export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) { export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
const id = `e${seq.counter++}`; const id = `e${seq.counter++}`;
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z"); const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`; const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag &amp; drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`; const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://inbuxa.org">Drag &amp; drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
const textBlob = putBlob(text, "text/plain"); const textBlob = putBlob(text, "text/plain");
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html"); const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
const attachments: Obj[] = []; const attachments: Obj[] = [];
@@ -192,7 +196,8 @@ export function addEmail(o: { from: [string, string]; to?: string; subject: stri
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null, from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "), subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [], // No HTML alternative means `htmlBody` names the text part, not nothing. See addSignedEmail.
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
attachments, attachments,
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) }, bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) },
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
+7 -7
View File
@@ -14,7 +14,7 @@ import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from
import { handlers } from "./handlers.js"; import { handlers } from "./handlers.js";
export { account } from "./config.js"; export { account } from "./config.js";
import { checkOtp } from "./auth.js"; import { checkOtp } from "./auth.js";
import { checkBearer, handleOAuth } from "./oauth.js"; import { basicRefused, checkBearer, handleOAuth } from "./oauth.js";
import { sseClients, broadcast } from "./events.js"; import { sseClients, broadcast } from "./events.js";
/* ---------- http ---------- */ /* ---------- http ---------- */
@@ -26,7 +26,7 @@ function unauthorized(res: ServerResponse) {
function checkAuth(req: IncomingMessage): boolean { function checkAuth(req: IncomingMessage): boolean {
const h = req.headers.authorization ?? ""; const h = req.headers.authorization ?? "";
if (checkBearer(h)) return true; if (checkBearer(h)) return true;
if (!h.startsWith("Basic ")) return false; if (!h.startsWith("Basic ") || basicRefused) return false;
const raw = Buffer.from(h.slice(6), "base64").toString(); const raw = Buffer.from(h.slice(6), "base64").toString();
const sep = raw.indexOf(":"); const sep = raw.indexOf(":");
if (sep < 0) return false; if (sep < 0) return false;
@@ -60,8 +60,8 @@ const session = () => ({
* the only way this stays honest about what can be inferred from a * the only way this stays honest about what can be inferred from a
* capability, which is nothing. * capability, which is nothing.
*/ */
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } }, accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) }, primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": ACCOUNT }) },
username: USER, username: USER,
apiUrl: `http://127.0.0.1:${PORT}/jmap/`, apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
@@ -103,7 +103,7 @@ export const server = createServer(async (req, res) => {
// call that wanted it - which is why an over-eager `using` is so damaging. // call that wanted it - which is why an over-eager `using` is so damaging.
// Stalwart decides this by parsing the urn, not by looking it up in the // Stalwart decides this by parsing the urn, not by looking it up in the
// session, so a capability it hands out per-account is still usable here: // session, so a capability it hands out per-account is still usable here:
// `urn:stalwart:jmap` never appears in the session-level capabilities and // `urn:inbuxa:jmap:registry` never appears in the session-level capabilities and
// the registry calls that name it work all the same. // the registry calls that name it work all the same.
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]); const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
const unknown = (body.using ?? []).find((u) => !known.has(u)); const unknown = (body.using ?? []).find((u) => !known.has(u));
@@ -204,8 +204,8 @@ export const server = createServer(async (req, res) => {
res.writeHead(404, { "content-type": "application/json" }); res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" })); res.end(JSON.stringify({ error: "not found" }));
}).listen(PORT, "127.0.0.1", () => { }).listen(PORT, "127.0.0.1", () => {
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`); console.log(`[mock-server] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
console.log(`[mock-stalwart] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`); console.log(`[mock-server] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`);
}); });
// Periodically inject a new inbox email to demo push // Periodically inject a new inbox email to demo push
+45 -1
View File
@@ -23,11 +23,18 @@ const refreshTokens = new Map<string, Grant>();
const base = () => `http://127.0.0.1:${PORT}`; const base = () => `http://127.0.0.1:${PORT}`;
/**
* Whether JMAP refuses Basic, as INBUXA's server does outside DAV (contract
* C-23). Off by default, since the mock also serves password sign-in.
*/
export let basicRefused = false;
/** For tests: how long new access tokens last, and a way to end every token. */ /** For tests: how long new access tokens last, and a way to end every token. */
export const oauthMock = { export const oauthMock = {
setAccessTokenTtl(seconds: number) { accessTokenTtl = seconds; }, setAccessTokenTtl(seconds: number) { accessTokenTtl = seconds; },
expireAccessTokens() { for (const t of accessTokens.values()) t.expiresAt = 0; }, expireAccessTokens() { for (const t of accessTokens.values()) t.expiresAt = 0; },
reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; }, refuseBasic(on: boolean) { basicRefused = on; },
reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; basicRefused = false; },
}; };
/** A bearer token the mock issued, still valid under the current password. */ /** A bearer token the mock issued, still valid under the current password. */
@@ -50,6 +57,14 @@ function readForm(req: IncomingMessage): Promise<URLSearchParams> {
}); });
} }
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks)));
});
}
function issue(res: ServerResponse, refresh: string | null) { function issue(res: ServerResponse, refresh: string | null) {
const access = `mock-at-${randomBytes(16).toString("hex")}`; const access = `mock-at-${randomBytes(16).toString("hex")}`;
accessTokens.set(access, { password: account.password, expiresAt: Date.now() + accessTokenTtl * 1000 }); accessTokens.set(access, { password: account.password, expiresAt: Date.now() + accessTokenTtl * 1000 });
@@ -93,6 +108,35 @@ export async function handleOAuth(req: IncomingMessage, res: ServerResponse, url
res.end(); res.end();
return true; return true;
} }
if (url.pathname === "/api/auth" && req.method === "POST") {
// The server's sign-in page posts here; the mock answers for the demo user.
let body: Record<string, unknown>;
try {
body = JSON.parse((await readBody(req)).toString()) as Record<string, unknown>;
} catch {
json(res, 400, { error: "invalid_request" });
return true;
}
const redirectUri = typeof body.redirectUri === "string" ? body.redirectUri : "";
if (body.type !== "authCode" || body.clientId !== OAUTH_CLIENT_ID || !redirectUri || body.codeChallengeMethod !== "S256") {
json(res, 400, { error: "invalid_request" });
return true;
}
const secret = typeof body.accountSecret === "string" ? body.accountSecret : "";
const passwordOk = body.accountName === USER && (secret === account.password || account.appPasswords.some((a) => a.secret === secret));
if (!passwordOk) {
json(res, 200, { type: "failure" });
return true;
}
if (account.otpUrl && secret === account.password && !body.mfaToken) {
json(res, 200, { type: "mfaRequired" });
return true;
}
const code = randomBytes(16).toString("hex");
codes.set(code, { challenge: String(body.codeChallenge ?? ""), redirectUri, issuedAt: Date.now() });
json(res, 200, { type: "authenticated", client_code: code, iss: base() });
return true;
}
if (url.pathname === "/auth/token" && req.method === "POST") { if (url.pathname === "/auth/token" && req.method === "POST") {
const form = await readForm(req); const form = await readForm(req);
if (form.get("client_id") !== OAUTH_CLIENT_ID || form.get("client_secret") !== OAUTH_CLIENT_SECRET) { if (form.get("client_id") !== OAUTH_CLIENT_ID || form.get("client_secret") !== OAUTH_CLIENT_SECRET) {
+49
View File
@@ -0,0 +1,49 @@
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { clearNodeCache, mailNode, ptrName } from "./nodes.js";
/**
* About names the mail node by the PTR of the address the server's name
* resolves to from the webmail -- the local node, where a host entry pins it.
*/
beforeEach(() => clearNodeCache());
const lookup = (address: string) => async () => ({ address });
test("the node is named by its address's PTR, without the trailing dot", async () => {
const n = await mailNode("https://mail.example.com", lookup("192.0.2.2"), async () => ["mx2.example.com."]);
assert.deepEqual(n, { host: "mail.example.com", address: "192.0.2.2", name: "mx2.example.com" });
});
test("an address without a PTR still shows the address", async () => {
const n = await mailNode("https://mail.example.com", lookup("192.0.2.3"), async () => { throw new Error("ENOTFOUND"); });
assert.deepEqual(n, { host: "mail.example.com", address: "192.0.2.3", name: null });
});
test("a name that doesn't resolve says so instead of failing", async () => {
let reversed = false;
const n = await mailNode("https://mail.example.com", async () => { throw new Error("ENOTFOUND"); }, async () => { reversed = true; return []; });
assert.deepEqual(n, { host: "mail.example.com", address: null, name: null });
assert.equal(reversed, false);
});
test("the answer is cached for a minute, then looked up again", async () => {
let calls = 0;
const count = async () => { calls++; return { address: "192.0.2.1" }; };
const rev = async () => ["mail.example.com"];
await mailNode("https://mail.example.com", count, rev, 0);
await mailNode("https://mail.example.com", count, rev, 59_000);
assert.equal(calls, 1);
await mailNode("https://mail.example.com", count, rev, 61_000);
assert.equal(calls, 2);
});
test("the PTR name is built for IPv4 and IPv6 alike", () => {
assert.equal(ptrName("192.0.2.52"), "52.2.0.192.in-addr.arpa");
assert.equal(
ptrName("2001:db8::25"),
"5.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa",
);
assert.equal(ptrName("::1"), "1" + ".0".repeat(31) + ".ip6.arpa");
});
+85
View File
@@ -0,0 +1,85 @@
/**
* ihasmail-inbuxa: which webmail node answered, and which inbuxa node it talks
* to -- shown in Settings > About, for troubleshooting a cluster.
*
* Not for upstream ihasmail: it only makes sense where one webmail runs per
* host, each pinned to its own host's mail node.
*
* The webmail node is NODE_NAME, set per host by the deploy; without it, the
* container's hostname, which is at least distinct. The mail node is worked
* out from the address the server's name resolves to *here*, where the host
* entry pins it to the local node, named by that address's PTR record (every
* node has one: mail/mx2/mx3). The server offers its node name only to
* administrators, so asking it would leave everyone else with nothing.
*/
import { lookup as dnsLookup, resolvePtr } from "node:dns/promises";
import { isIPv4 } from "node:net";
import { hostname } from "node:os";
import { config } from "./config.js";
export interface MailNode {
/** The server name the webmail connects to, from its configured URL. */
host: string;
/** What that name resolves to from this container, or null if it doesn't. */
address: string | null;
/** The address's PTR name: the node's own name. Null without a PTR. */
name: string | null;
}
export interface Nodes {
webmail: string;
mailServer: MailNode;
}
type Lookup = (host: string) => Promise<{ address: string }>;
type Reverse = (address: string) => Promise<string[]>;
/**
* The reverse-lookup name for an address: 1.2.0.192.in-addr.arpa, or the
* nibble form under ip6.arpa. Asked for directly, because `dns.reverse` came
* back empty in the image while the resolver answered the PTR (2026-09-26).
*/
export function ptrName(address: string): string {
if (isIPv4(address)) return `${address.split(".").reverse().join(".")}.in-addr.arpa`;
const [head = "", tail = ""] = address.split("::");
const groups = (part: string) => (part ? part.split(":") : []);
const h = groups(head), t = groups(tail);
const full = [...h, ...Array(8 - h.length - t.length).fill("0"), ...t];
return `${full.map((g) => g.padStart(4, "0")).join("").split("").reverse().join(".")}.ip6.arpa`;
}
const dnsReverse: Reverse = (address) => resolvePtr(ptrName(address));
const CACHE_MS = 60_000;
const cache = new Map<string, { node: MailNode; at: number }>();
export function webmailNode(): string {
return config.nodeName || hostname();
}
export async function mailNode(base: string, lookup: Lookup = dnsLookup, reverse: Reverse = dnsReverse, now = Date.now()): Promise<MailNode> {
const host = new URL(base).hostname;
const hit = cache.get(host);
if (hit && now - hit.at < CACHE_MS) return hit.node;
let address: string | null = null;
let name: string | null = null;
try {
address = (await lookup(host)).address;
} catch {
/* unresolvable: say so rather than fail the page */
}
if (address) {
try {
name = (await reverse(address))[0]?.replace(/\.$/, "") ?? null;
} catch {
/* no PTR */
}
}
const node = { host, address, name };
cache.set(host, { node, at: now });
return node;
}
export function clearNodeCache(): void {
cache.clear();
}
+2
View File
@@ -198,6 +198,8 @@ test("a password change signs the session out, since the server revokes its toke
test("creating an app password checks the typed password with the server", async () => { test("creating an app password checks the typed password with the server", async () => {
await signIn(); await signIn();
// As INBUXA's server does: no password over JMAP (contract C-23).
oauthMock.refuseBasic(true);
const wrong = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "nope" }) }); const wrong = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "nope" }) });
assert.equal(wrong.status, 403); assert.equal(wrong.status, 403);
const right = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "demo-password" }) }); const right = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "demo-password" }) });
+33
View File
@@ -134,6 +134,39 @@ export async function start(params: { username: string; base: string; remember:
return { location: url.toString(), state }; return { location: url.toString(), state };
} }
/**
* Whether `password` is the account's password, for a session that holds a
* token and so has no password to compare with.
*
* Asked of the server's sign-in endpoint, the one its own sign-in page posts
* to, because the server takes no password over JMAP (contract C-23). The
* request is this client's, to its registered redirect URI, so it passes the
* same checks a real sign-in does. A code it issues can never be exchanged:
* the PKCE verifier behind its challenge is thrown away here.
*
* "Two-factor code needed" counts as confirmed: the server says so only once
* the password has matched.
*/
export async function passwordConfirms(params: { base: string; username: string; password: string }): Promise<boolean> {
const res = await fetch(absoluteUpstream("/api/auth", params.base), {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
type: "authCode",
accountName: params.username,
accountSecret: params.password,
clientId: config.oauthClientId,
redirectUri: redirectUri(),
codeChallenge: challengeOf(randomToken(48)),
codeChallengeMethod: "S256",
}),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) throw new UpstreamError(`Password check failed (${res.status})`, 502);
const answer = (await res.json()) as { type?: string };
return answer.type === "authenticated" || answer.type === "mfaRequired";
}
export class SignInError extends Error { export class SignInError extends Error {
constructor(readonly code: "state_mismatch" | "expired" | "denied" | "exchange_failed", message: string) { constructor(readonly code: "state_mismatch" | "expired" | "denied" | "exchange_failed", message: string) {
super(message); super(message);
+2 -2
View File
@@ -178,14 +178,14 @@ export function forgetUpstreamSession(sessionId: string): void {
/* Account locale */ /* Account locale */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const STALWART_CAP = "urn:stalwart:jmap"; const STALWART_CAP = "urn:inbuxa:jmap:registry";
const JMAP_CORE = "urn:ietf:params:jmap:core"; const JMAP_CORE = "urn:ietf:params:jmap:core";
/** /**
* Whether this server has Stalwart's JMAP registry — the `x:` objects that * Whether this server has Stalwart's JMAP registry — the `x:` objects that
* carry credentials, account settings and the newer FileNode shape. * carry credentials, account settings and the newer FileNode shape.
* *
* `urn:stalwart:jmap` is the marker, but **not** in the session-level * `urn:inbuxa:jmap:registry` is the marker, but **not** in the session-level
* `capabilities`, which is where a JMAP client would naturally look. Stalwart * `capabilities`, which is where a JMAP client would naturally look. Stalwart
* builds that list from a fixed set that has never included this capability; * builds that list from a fixed set that has never included this capability;
* it hands it out per-account instead, so it turns up in `primaryAccounts` and * it hands it out per-account instead, so it turns up in `primaryAccounts` and
+4 -4
View File
@@ -19,11 +19,11 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico?v=2026-09-27a" sizes="any" />
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" /> <link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png?v=2026-09-27a" />
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/img/apple-touch-icon.png?v=2026-09-27a" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>INBUXA</title> <title>inbuxa</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "INBUXA", "name": "inbuxa",
"short_name": "INBUXA", "short_name": "inbuxa",
"description": "INBUXA webmail", "description": "inbuxa webmail",
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.", "_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
"_comment_id": "There is deliberately no `id`. It is the one member NOT resolved against this file's address -- the spec resolves it against the origin of start_url, so `./`, `mail` and `/mail` all mean the same thing at the domain root and none of them can name a subpath mount. Adding one would therefore break the same thing the note above describes. Worse, the default id IS start_url, which is already mount-correct: writing an id now would give every installed copy a new identity and orphan it as a second app rather than updating it. If one is ever wanted it has to be substituted at build time from BASE_PATH, and the changeover costs everybody their install.", "_comment_id": "There is deliberately no `id`. It is the one member NOT resolved against this file's address -- the spec resolves it against the origin of start_url, so `./`, `mail` and `/mail` all mean the same thing at the domain root and none of them can name a subpath mount. Adding one would therefore break the same thing the note above describes. Worse, the default id IS start_url, which is already mount-correct: writing an id now would give every installed copy a new identity and orphan it as a second app rather than updating it. If one is ever wanted it has to be substituted at build time from BASE_PATH, and the changeover costs everybody their install.",
"start_url": "mail", "start_url": "mail",
@@ -51,17 +51,17 @@
"theme_color": "#0f766e", "theme_color": "#0f766e",
"icons": [ "icons": [
{ {
"src": "img/icon-192.png", "src": "img/icon-192.png?v=2026-09-27a",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "img/icon-512.png", "src": "img/icon-512.png?v=2026-09-27a",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "img/icon-maskable.png", "src": "img/icon-maskable.png?v=2026-09-27a",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
+8 -6
View File
@@ -18,7 +18,9 @@ const VERSION = "ihasmail-v2";
* eventually would. * eventually would.
*/ */
const BASE = new URL("./", self.location).pathname.replace(/\/$/, ""); const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`]; // The brand images' version; the same value as BRAND_V in src/lib/brand.ts.
const BRAND_V = "2026-09-27a";
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png?v=${BRAND_V}`, `${BASE}/img/icon-192.png?v=${BRAND_V}`, `${BASE}/favicon.ico?v=${BRAND_V}`];
/* /*
* Only the app page may be kept as the app page. * Only the app page may be kept as the app page.
@@ -482,7 +484,7 @@ self.addEventListener("push", (event) => {
// or a payload too large to carry the message. Say something true // or a payload too large to carry the message. Say something true
// rather than inventing a sender. // rather than inventing a sender.
await self.registration.showNotification(strings.newMail, { await self.registration.showNotification(strings.newMail, {
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` }, icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`, badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
}); });
return; return;
} }
@@ -492,8 +494,8 @@ self.addEventListener("push", (event) => {
const { title, body, preview } = textOf(email, strings); const { title, body, preview } = textOf(email, strings);
await self.registration.showNotification(title, { await self.registration.showNotification(title, {
body: preview ? `${body}\n${preview}` : body, body: preview ? `${body}\n${preview}` : body,
icon: `${BASE}/img/icon-192.png`, icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
badge: `${BASE}/img/favicon-64.png`, badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
tag: `ihasmail-${email.id || body}`, tag: `ihasmail-${email.id || body}`,
// Only where there is a message to act on: a payload without an id can // Only where there is a message to act on: a payload without an id can
// be shown but not archived, and a button that cannot work should not // be shown but not archived, and a button that cannot work should not
@@ -537,8 +539,8 @@ async function runAction(action, data) {
} catch { } catch {
await self.registration.showNotification(data.title || "ihasmail", { await self.registration.showNotification(data.title || "ihasmail", {
body: data.failed || "Could not do that — open ihasmail and try again", body: data.failed || "Could not do that — open ihasmail and try again",
icon: `${BASE}/img/icon-192.png`, icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
badge: `${BASE}/img/favicon-64.png`, badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
tag: `ihasmail-failed-${id}`, tag: `ihasmail-failed-${id}`,
data: { url: data.url }, data: { url: data.url },
}); });
+74 -17
View File
@@ -1,7 +1,7 @@
import { Fragment, lazy, Suspense, useEffect, useState } from "react"; import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react";
import { Route, Switch, Redirect, useLocation, Router } from "wouter"; import { Route, Switch, Redirect, useLocation, Router } from "wouter";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useMail } from "@/store/mail"; import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail";
import { scheduleSupported, useScheduled } from "@/store/scheduled"; import { scheduleSupported, useScheduled } from "@/store/scheduled";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar"; import { useCalendar } from "@/store/calendar";
@@ -19,7 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify"; import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
import { publishWorkerFacts } from "@/lib/sw/swFacts"; import { publishWorkerFacts } from "@/lib/sw/swFacts";
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings"; import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync"; import { armSettingsSync, loadRemoteSettings, loadSettingsOnce, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { loadSettingsPolicy } from "@/lib/settingsPolicy"; import { loadSettingsPolicy } from "@/lib/settingsPolicy";
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable"; import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n"; import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
@@ -119,6 +119,7 @@ export function App() {
function AuthedApp() { function AuthedApp() {
const accountId = useSession((s) => s.accountId); const accountId = useSession((s) => s.accountId);
const viewing = useSession((s) => s.viewing);
const [location] = useLocation(); const [location] = useLocation();
/* /*
@@ -140,22 +141,22 @@ function AuthedApp() {
* Once per account, not once per mount: this subtree is keyed on the * Once per account, not once per mount: this subtree is keyed on the
* language version, so picking a language throws it away and builds it * language version, so picking a language throws it away and builds it
* again. Re-reading the settings file there would apply a copy written * again. Re-reading the settings file there would apply a copy written
* before the change and undo it. * before the change and undo it. And the load is not this mount's to
* cancel: the settings file choosing a language remounts the tree midway
* through it, and a load cut off there never armed the pushes, so nothing
* changed afterwards was saved (Gitea issue #23). `loadSettingsOnce` runs
* it to the end and lets every mount wait on it.
*/ */
const [ready, setReady] = useState(PAINTED_FROM_CACHE); const [ready, setReady] = useState(PAINTED_FROM_CACHE);
useEffect(() => { useEffect(() => {
if (settingsAlreadyLoadedFor(accountId)) {
setReady(true);
return;
}
let canceled = false; let canceled = false;
void (async () => { void loadSettingsOnce(accountId, async (isCurrent) => {
/* Before the account's own settings, so both the seeding below and the /* Before the account's own settings, so both the seeding below and the
enforcement inside `hydrate` have something to apply. */ enforcement inside `hydrate` have something to apply. */
await loadSettingsPolicy(); await loadSettingsPolicy();
if (canceled) return; if (!isCurrent()) return;
const remote = await loadRemoteSettings(); const remote = await loadRemoteSettings();
if (canceled) return; if (!isCurrent()) return;
if (remote) useSettings.getState().hydrate(remote); if (remote) useSettings.getState().hydrate(remote);
// No settings file: this account has never had settings of its own, so // No settings file: this account has never had settings of its own, so
// the installation's defaults are what it starts on rather than // the installation's defaults are what it starts on rather than
@@ -177,15 +178,16 @@ function AuthedApp() {
// The catalog for whatever language that turned out to be. Hydrating // The catalog for whatever language that turned out to be. Hydrating
// asks for it; this is waiting for the answer. // asks for it; this is waiting for the answer.
await whenLanguageReady(); await whenLanguageReady();
if (canceled) return; if (!isCurrent()) return;
setReady(true);
// Pushes were held back until now so they could not race the load. A // Pushes were held back until now so they could not race the load. A
// change made while it was in flight was kept, and goes out here. // change made while it was in flight was kept, and goes out here.
armSettingsSync(); armSettingsSync();
// No file yet — seed one from what this browser has, so the next device // No file yet — seed one from what this browser has, so the next device
// to sign in starts from these rather than from the defaults. // to sign in starts from these rather than from the defaults.
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings)); if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
})(); }).then(() => {
if (!canceled) setReady(true);
});
return () => { return () => {
canceled = true; canceled = true;
}; };
@@ -230,6 +232,9 @@ function AuthedApp() {
timer = null; timer = null;
for (const [a, types] of pending) { for (const [a, types] of pending) {
if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types); if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types);
// inbuxa AL-7: the reader's own mail, while a delegated account
// is in view, is still announced
if (a === ownAccountAway() && types.has("Email")) void notifyOwnWhileAway();
if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types); if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types);
if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types); if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types);
if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types); if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types);
@@ -253,16 +258,65 @@ function AuthedApp() {
}; };
}, [accountId]); }, [accountId]);
/*
* inbuxa AL-7: a delegated account, the whole of it, when it comes into
* view, and the reader's own when it goes back. Push carries nothing for an account only
* shared with the reader, so while one is open it is polled.
*/
const viewedOnce = useRef(false);
useEffect(() => {
if (!viewedOnce.current) {
viewedOnce.current = true;
if (!viewing) return;
}
const mail = useMail.getState();
void mail.loadMailboxes();
void mail.loadIdentities();
// The whole account follows: calendar, contacts and files too
void useCalendar.getState().init();
void useContacts.getState().init();
void useFiles.getState().init();
if (!viewing) return;
const poll = window.setInterval(() => {
if (document.visibilityState === "visible") {
void useMail.getState().applyChanges(new Set(["Email", "Mailbox"]));
}
}, 60_000);
return () => window.clearInterval(poll);
}, [viewing]);
// inbuxa AL-7: a delegation given or taken away while the app is open shows
// up when the reader comes back to it, without signing in again
useEffect(() => {
let last = 0;
const onVisible = () => {
if (document.visibilityState !== "visible" || Date.now() - last < 60_000) return;
last = Date.now();
void useSession.getState().refresh();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, []);
const delegationEnded = useSession((s) => s.delegationEnded);
useEffect(() => {
if (!delegationEnded) return;
toast.show(t("You no longer have access to {name}. Back to your own mail.", { name: delegationEnded }));
useSession.getState().clearDelegationEnded();
}, [delegationEnded]);
// Unread badge in title/favicon // Unread badge in title/favicon
const inboxUnread = useMail((s) => { const inboxUnread = useMail((s) => {
const id = s.roleId("inbox"); const id = s.roleId("inbox");
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0; return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
}); });
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; 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
const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
useEffect(() => { useEffect(() => {
setBaseTitle(appName); setBaseTitle(viewingName ? `🔒 ${viewingName} · ${appName}` : appName);
setUnreadBadge(inboxUnread); setUnreadBadge(inboxUnread);
}, [inboxUnread, appName]); }, [inboxUnread, appName, viewingName]);
/* /*
* Leave the service worker its briefing. * Leave the service worker its briefing.
@@ -276,8 +330,11 @@ function AuthedApp() {
const archiveId = useMail((s) => s.roleId("archive")); const archiveId = useMail((s) => s.roleId("archive"));
const languageVersion = useLanguageVersion(); const languageVersion = useLanguageVersion();
useEffect(() => { 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); void publishWorkerFacts(accountId, archiveId);
}, [accountId, archiveId, languageVersion]); }, [accountId, archiveId, languageVersion, viewing]);
// Request notification permission lazily when enabled // Request notification permission lazily when enabled
const notif = useSettings((s) => s.settings.desktopNotifications); const notif = useSettings((s) => s.settings.desktopNotifications);
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { INBUXA_CAP, legacyProtocolsPartlyOff } from "../client";
import { parseTenantLegacy } from "@/lib/admin/adminLegacyProtocols";
import type { JmapSession } from "../types";
/**
* INBUXA can switch IMAP, POP3 and ManageSieve off one at a time. The session
* lists what is still allowed (`legacyAllowed`); the webmail names what isn't,
* only while some but not all are off, and never from a server that doesn't
* say.
*/
describe("legacyProtocolsPartlyOff", () => {
const session = (cap: Record<string, unknown> | undefined) =>
({
accounts: { a: { name: "[email protected]", accountCapabilities: cap ? { [INBUXA_CAP]: cap } : {} } },
}) as unknown as JmapSession;
it("names what is off when only some are", () => {
const s = session({ legacyProtocols: "enabled", legacyAllowed: ["imap", "manageSieve", "submission"] });
expect(legacyProtocolsPartlyOff(s, "a")).toEqual(["POP3"]);
const two = session({ legacyProtocols: "enabled", legacyAllowed: ["pop3", "submission"] });
expect(legacyProtocolsPartlyOff(two, "a")).toEqual(["IMAP", "ManageSieve"]);
});
it("is empty with none off, all off, or an older server", () => {
const all = ["imap", "pop3", "manageSieve", "submission"];
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "enabled", legacyAllowed: all }), "a")).toEqual([]);
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "disabled", legacyAllowed: [] }), "a")).toEqual([]);
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "enabled" }), "a")).toEqual([]);
expect(legacyProtocolsPartlyOff(null, "a")).toEqual([]);
});
});
describe("parseTenantLegacy", () => {
it("reads a tenant with only some off, and an older server's one switch", () => {
expect(parseTenantLegacy({ legacyProtocols: "enabled", pop3: "disabled" }).partlyOff).toEqual(["POP3"]);
const all = parseTenantLegacy({ legacyProtocols: "disabled", imap: "disabled", pop3: "disabled" });
expect(all.off).toBe(true);
expect(all.partlyOff).toEqual([]);
expect(parseTenantLegacy({ legacyProtocols: "enabled" }).partlyOff).toEqual([]);
});
});
+20 -2
View File
@@ -20,7 +20,7 @@ export const CAP = {
} as const; } as const;
/** Stalwart's own capability, which carries its `x:` registry methods. */ /** Stalwart's own capability, which carries its `x:` registry methods. */
export const STALWART_CAP = "urn:stalwart:jmap"; export const STALWART_CAP = "urn:inbuxa:jmap:registry";
/** INBUXA's own capability (contract C-1), on the signed-in account. */ /** INBUXA's own capability (contract C-1), on the signed-in account. */
export const INBUXA_CAP = "urn:inbuxa:jmap"; export const INBUXA_CAP = "urn:inbuxa:jmap";
@@ -37,6 +37,24 @@ export function legacyProtocolsOff(session: JmapSession | null, accountId: Id |
return cap?.legacyProtocols === "disabled"; return cap?.legacyProtocols === "disabled";
} }
/** The protocols the server can switch off one at a time, as it names them. */
const SWITCHED = ["imap", "pop3", "manageSieve"] as const;
const PROTOCOL_NAMES: Record<string, string> = { imap: "IMAP", pop3: "POP3", manageSieve: "ManageSieve" };
/**
* Which of IMAP, POP3 and ManageSieve are off for this account, by name, when
* only some are (INBUXA legacy-protocols, one switch per protocol). Empty when
* none are, when all are (see `legacyProtocolsOff`), and from a server that
* doesn't say which (`legacyAllowed`).
*/
export function legacyProtocolsPartlyOff(session: JmapSession | null, accountId: Id | null): string[] {
if (!session || !accountId || legacyProtocolsOff(session, accountId)) return [];
const cap = session.accounts[accountId]?.accountCapabilities?.[INBUXA_CAP] as { legacyAllowed?: unknown } | undefined;
if (!Array.isArray(cap?.legacyAllowed)) return [];
const allowed = cap.legacyAllowed;
return SWITCHED.filter((p) => !allowed.includes(p)).map((p) => PROTOCOL_NAMES[p] ?? p);
}
export class JmapMethodError extends Error { export class JmapMethodError extends Error {
constructor( constructor(
public readonly method: string, public readonly method: string,
@@ -155,7 +173,7 @@ export class JmapClient {
* Whether the server carries a capability at all, wherever it chose to * Whether the server carries a capability at all, wherever it chose to
* advertise it. * advertise it.
* *
* Stalwart hands `urn:stalwart:jmap` out per-account rather than putting it * Stalwart hands `urn:inbuxa:jmap:registry` out per-account rather than putting it
* in the session-level `capabilities`, so `hasCapability` alone reports every * in the session-level `capabilities`, so `hasCapability` alone reports every
* real 0.16 server as though it were older. Look in all three places. * real 0.16 server as though it were older. Look in all three places.
*/ */
+2
View File
@@ -268,6 +268,8 @@ export interface Email {
"header:Received:asText:all"?: string[] | null; "header:Received:asText:all"?: string[] | null;
"header:X-Spam-Status:asText"?: string | null; "header:X-Spam-Status:asText"?: string | null;
"header:X-Spam-Result:asText"?: string | null; "header:X-Spam-Result:asText"?: string | null;
/** inbuxa: the language model's opinion, when AI spam classification is on (lib/llmOpinion). */
"header:X-Spam-LLM:asText"?: string | null;
} }
export interface Thread { export interface Thread {
+4 -9
View File
@@ -29,15 +29,10 @@ describe("resolveUiLanguage", () => {
expect(resolveUiLanguage("xx-XX")).toBe("en"); expect(resolveUiLanguage("xx-XX")).toBe("en");
}); });
it("carries the Beta flag until a person has signed the language off", () => { it("marks no language Beta", () => {
// Not a completeness measure. A catalog can be word-for-word finished // inbuxa: every shipped language is offered without the Beta mark
// and still read like a machine wrote it, which is what this marks. // (John, 2026-09-27); only Dutch has been read by a native speaker.
// Every shipped language except English is unreviewed, and stays marked for (const l of UI_LANGUAGES) expect(l.beta).toBeUndefined();
// until a person says otherwise.
for (const l of UI_LANGUAGES) {
if (l.tag === "en") expect(l.beta).toBeUndefined();
else expect(l.beta).toBe(true);
}
}); });
it("honors one that is", () => { it("honors one that is", () => {
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { LLM_HEADER_PROP, llmOpinion, parseLlmOpinion } from "@/lib/llmOpinion";
/*
* The header as inbuxa-server writes it (crates/features/src/ai/answer.rs):
* `X-Spam-LLM: <TAG>`, optionally followed by the explanation in one pair of
* parentheses, folded at 78 columns.
*/
describe("parseLlmOpinion", () => {
it("reads category, confidence and explanation", () => {
expect(parseLlmOpinion("LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about)")).toEqual({
tag: "LLM_UNSOLICITED_HIGH",
category: "Unsolicited",
confidence: "High",
explanation: "Promotes a product the reader never asked about",
});
});
it("reads a tag with no confidence and no explanation", () => {
expect(parseLlmOpinion("LLM_LEGITIMATE")).toEqual({
tag: "LLM_LEGITIMATE",
category: "Legitimate",
confidence: null,
explanation: null,
});
});
it("keeps an operator's multi-word category whole", () => {
const o = parseLlmOpinion("LLM_COLD_OUTREACH_MEDIUM");
expect(o?.category).toBe("Cold outreach");
expect(o?.confidence).toBe("Medium");
// An unknown last word is part of the category, not a confidence.
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.category).toBe("Cold outreach");
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.confidence).toBeNull();
});
it("unfolds a folded header and keeps inner parentheses", () => {
const o = parseLlmOpinion("LLM_HARMFUL_LOW (Asks for a password\r\n (urgently) via a link)");
expect(o?.explanation).toBe("Asks for a password (urgently) via a link");
});
it("returns null for anything that isn't the server's tag", () => {
for (const raw of [null, undefined, "", " ", "Yes, score=6.7", "LLM_", "llm_unsolicited_high", "X LLM_SPAM"]) {
expect(parseLlmOpinion(raw), String(raw)).toBeNull();
}
});
it("reads the JMAP property a full message carries", () => {
expect(llmOpinion({ [LLM_HEADER_PROP]: "LLM_LEGITIMATE_HIGH" })?.category).toBe("Legitimate");
expect(llmOpinion({})).toBeNull();
});
});
+55 -8
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings"; import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
import { isAppFolder } from "../appFolder"; import { isAppFolder } from "../appFolder";
import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync"; import { loadSettingsOnce, stopSettingsSync } from "../settingsSync";
/** /**
* Settings used to live only in localStorage, so nothing followed the user * Settings used to live only in localStorage, so nothing followed the user
@@ -116,20 +116,67 @@ describe("a change made but not yet written up", () => {
expect(merged.uiLanguage).toBe("en"); expect(merged.uiLanguage).toBe("en");
}); });
it("reads the file again for an account after a sign-out", () => { it("reads the file again for an account after a sign-out", async () => {
stopSettingsSync(); stopSettingsSync();
expect(settingsAlreadyLoadedFor("a1")).toBe(false); let loads = 0;
const load = async () => { loads++; };
await loadSettingsOnce("a1", load);
// The remount that a language change causes must not read it a second time. // The remount that a language change causes must not read it a second time.
expect(settingsAlreadyLoadedFor("a1")).toBe(true); await loadSettingsOnce("a1", load);
expect(loads).toBe(1);
// Signing out drops the claim, so signing back in reads the file rather // Signing out drops the claim, so signing back in reads the file rather
// than trusting whatever the previous session left behind. // than trusting whatever the previous session left behind.
stopSettingsSync(); stopSettingsSync();
expect(settingsAlreadyLoadedFor("a1")).toBe(false); await loadSettingsOnce("a1", load);
expect(loads).toBe(2);
stopSettingsSync(); stopSettingsSync();
}); });
it("treats a missing account as already loaded, so nothing is fetched", () => { it("finishes a load that a remount interrupts, so changes are saved (Gitea #23)", async () => {
expect(settingsAlreadyLoadedFor(null)).toBe(true); stopSettingsSync();
expect(settingsAlreadyLoadedFor(undefined)).toBe(true); let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
let loads = 0;
let armed = false;
const load = async (isCurrent: () => boolean) => {
loads++;
await gate;
if (!isCurrent()) return;
armed = true;
};
// First mount starts the load; the settings file picks a language and the
// tree remounts before the load has finished.
const first = loadSettingsOnce("a1", load);
const second = loadSettingsOnce("a1", load);
expect(second).toBe(first);
release();
await second;
expect(loads).toBe(1);
// This is what used to be skipped: the remounted tree took the
// already-loaded path and nothing armed the pushes.
expect(armed).toBe(true);
stopSettingsSync();
});
it("stops a load that a sign-out overtakes", async () => {
stopSettingsSync();
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
let applied = false;
const pending = loadSettingsOnce("a1", async (isCurrent) => {
await gate;
if (isCurrent()) applied = true;
});
stopSettingsSync();
release();
await pending;
expect(applied).toBe(false);
});
it("treats a missing account as already loaded, so nothing is fetched", async () => {
let loads = 0;
await loadSettingsOnce(null, async () => { loads++; });
await loadSettingsOnce(undefined, async () => { loads++; });
expect(loads).toBe(0);
}); });
}); });
@@ -3,8 +3,8 @@ import { CONFIRM_PHRASE, impactEntries, parseTenantLegacy, phraseMatches } from
describe("a tenant's legacy mail protocols switch, as the server sends it", () => { describe("a tenant's legacy mail protocols switch, as the server sends it", () => {
it("reads the switch, and tells an older server from nobody", () => { it("reads the switch, and tells an older server from nobody", () => {
expect(parseTenantLegacy({ legacyProtocols: "disabled", recentLegacyUse: [] })).toEqual({ off: true, recent: [] }); expect(parseTenantLegacy({ legacyProtocols: "disabled", recentLegacyUse: [] })).toEqual({ off: true, partlyOff: [], recent: [] });
expect(parseTenantLegacy({ legacyProtocols: "enabled" })).toEqual({ off: false, recent: null }); expect(parseTenantLegacy({ legacyProtocols: "enabled" })).toEqual({ off: false, partlyOff: [], recent: null });
}); });
it("puts each account on the panel once, with every protocol and its latest use", () => { it("puts each account on the panel once, with every protocol and its latest use", () => {
+9 -1
View File
@@ -34,7 +34,10 @@ export interface RecentUse {
} }
export interface TenantLegacy { export interface TenantLegacy {
/** All of IMAP, POP3 and ManageSieve off: the kill-all's state. */
off: boolean; off: boolean;
/** When only some are off, which, by name; set one at a time in the console. */
partlyOff: string[];
/** Null from a server too old to say who uses legacy apps -- not the same as nobody. */ /** Null from a server too old to say who uses legacy apps -- not the same as nobody. */
recent: RecentUse[] | null; recent: RecentUse[] | null;
} }
@@ -49,8 +52,13 @@ function parseRecent(raw: unknown): RecentUse[] | null {
}); });
} }
const PROTOCOL_NAMES: [string, string][] = [["imap", "IMAP"], ["pop3", "POP3"], ["manageSieve", "ManageSieve"]];
export function parseTenantLegacy(raw: Record<string, unknown>): TenantLegacy { export function parseTenantLegacy(raw: Record<string, unknown>): TenantLegacy {
return { off: raw.legacyProtocols === "disabled", recent: parseRecent(raw.recentLegacyUse) }; const off = raw.legacyProtocols === "disabled";
// An older server sends only legacyProtocols, which stands for all three.
const partlyOff = off ? [] : PROTOCOL_NAMES.filter(([key]) => raw[key] === "disabled").map(([, name]) => name);
return { off, partlyOff, recent: parseRecent(raw.recentLegacyUse) };
} }
/** The tenant's switch, or null where the server has none (not INBUXA, or too old). */ /** The tenant's switch, or null where the server has none (not INBUXA, or too old). */
+25 -2
View File
@@ -1,4 +1,5 @@
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { withBase } from "@/lib/basePath";
/** /**
* What this instance calls itself, when nothing has said otherwise yet. * What this instance calls itself, when nothing has said otherwise yet.
@@ -12,9 +13,9 @@ import { useSession } from "@/store/session";
* One constant rather than the string written out at each of them, because * One constant rather than the string written out at each of them, because
* three copies of a default is how two of them end up stale. * three copies of a default is how two of them end up stale.
*/ */
// ihasmail-inbuxa: INBUXA's webmail goes by INBUXA, so it can't be taken for // ihasmail-inbuxa: inbuxa's webmail goes by inbuxa, so it can't be taken for
// public ihasmail. APP_NAME still names a deployment whatever it likes. // public ihasmail. APP_NAME still names a deployment whatever it likes.
export const DEFAULT_APP_NAME = "INBUXA"; export const DEFAULT_APP_NAME = "inbuxa";
/** /**
* What this instance calls itself, right now. * What this instance calls itself, right now.
* *
@@ -36,3 +37,25 @@ export function useAppName(): string {
export function currentAppName(): string { export function currentAppName(): string {
return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME; return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME;
} }
/**
* The brand images' version, carried as `?v=` on every URL that names one.
*
* The images live in `public/img` under fixed names and are served with a
* browser cache of hours, so replacing one (the mark changed on 2026-09-27)
* left returning visitors on the old picture until their copy expired -- and
* the favicon and an installed app's icon hold on longer still. A new value
* here is a new URL everywhere at once.
*
* Date-stamped with a letter for a second change the same day, never a
* counter, for the reason the sites give for ASSET_V: a value that could have
* been requested before may already be cached, with old bytes behind it.
* `index.html`, `public/manifest.webmanifest` and `public/sw.js` can't import
* this, so they carry the same value written out; keep the four in step.
*/
export const BRAND_V = "2026-09-27a";
/** A brand image's URL under the mount, versioned: `brandImage("/img/logo.png")`. */
export function brandImage(path: string): string {
return withBase(`${path}?v=${BRAND_V}`);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Locked accounts handed to the reader (inbuxa audit-hold-lock spec, AL-7).
*
* The server marks one in the account's `urn:inbuxa:jmap` capability, as
* `delegation: {locked, access, sendAs, until}`. Only that mark makes an
* account switchable: a server advertises every capability on any shared
* account, so "it has mail" proves nothing about what was handed over.
*/
import type { Id, JmapSession } from "@/jmap/types";
export type DelegationAccess = "read" | "organize" | "full";
export interface Delegation {
locked: boolean;
access: DelegationAccess;
sendAs: boolean;
/** UTC date the delegation ends, if it does. */
until: string | null;
}
export interface DelegatedAccount {
id: Id;
name: string;
delegation: Delegation;
}
const INBUXA = "urn:inbuxa:jmap";
type SessionLike = Pick<JmapSession, "accounts">;
export function delegationOf(session: SessionLike | null, accountId: Id | null): Delegation | null {
if (!session || !accountId) return null;
const account = session.accounts[accountId];
if (!account || account.isPersonal) return null;
const raw = (account.accountCapabilities?.[INBUXA] as { delegation?: Partial<Delegation> } | undefined)?.delegation;
if (!raw || raw.locked !== true) return null;
const access: DelegationAccess = raw.access === "organize" || raw.access === "full" ? raw.access : "read";
return {
locked: true,
access,
sendAs: raw.sendAs === true && access !== "read",
until: typeof raw.until === "string" ? raw.until : null,
};
}
/** Every locked account handed to the reader, by name. */
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;
})
.filter((a): a is DelegatedAccount => a !== null)
.sort((a, b) => a.name.localeCompare(b.name));
}
/** Whether the reader may change anything in the account (AL-6). */
export function mayWrite(delegation: Delegation | null): boolean {
return !delegation || delegation.access !== "read";
}
/** Whether the reader may delete in the account (AL-6). */
export function mayDestroy(delegation: Delegation | null): boolean {
return !delegation || delegation.access === "full";
}
+12 -9
View File
@@ -35,17 +35,20 @@ export interface UiLanguage {
beta?: boolean; beta?: boolean;
} }
// inbuxa: no language is marked Beta (John, 2026-09-27). The flag and its
// settings note stay, so public ihasmail's changes to them still apply.
export const UI_LANGUAGES: readonly UiLanguage[] = [ export const UI_LANGUAGES: readonly UiLanguage[] = [
{ tag: "en", name: "English" }, { tag: "en", name: "English" },
{ tag: "de", name: "Deutsch", beta: true }, { tag: "de", name: "Deutsch" },
{ tag: "es", name: "Español", beta: true }, { tag: "es", name: "Español" },
{ tag: "fr", name: "Français", beta: true }, { tag: "fr", name: "Français" },
{ tag: "nl", name: "Nederlands", beta: true }, { tag: "nl", name: "Nederlands" },
{ tag: "pt-BR", name: "Português (Brasil)", beta: true }, { tag: "pt-BR", name: "Português (Brasil)" },
{ tag: "ja", name: "日本語", beta: true }, { tag: "ja", name: "日本語" },
{ tag: "ru", name: "Русский", beta: true }, { tag: "ru", name: "Русский" },
{ tag: "uk", name: "Українська", beta: true }, { tag: "uk", name: "Українська" },
{ tag: "zh-Hans", name: "简体中文", beta: true }, { tag: "zh-Hans", name: "简体中文" },
{ tag: "tr", name: "Türkçe" },
]; ];
/** Where to report a bad translation. Beta languages depend on it. */ /** Where to report a bad translation. Beta languages depend on it. */
+80
View File
@@ -0,0 +1,80 @@
/**
* inbuxa: the language model's opinion, read back off the message.
*
* When the server's AI spam classification is on (inbuxa-server,
* docs/spec/features/ai-spam-classification.md), it writes the model's answer
* into an `X-Spam-LLM` header at delivery:
*
* X-Spam-LLM: LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about)
*
* a tag, then optionally the model's explanation in parentheses. The tag is
* `LLM_` + category, or `LLM_` + category + `_` + confidence, uppercased with
* anything outside A-Z and 0-9 turned into `_`. The explanation is already
* sanitized by the server and may arrive as encoded words, which the JMAP
* `asText` form decodes.
*
* Like `spamScore`, nothing here judges anything: it only reads what the
* server wrote. It is one signal the spam filter weighed among many, and the
* UI says so.
*/
/** The JMAP property that carries the header, decoded and unfolded. */
export const LLM_HEADER_PROP = "header:X-Spam-LLM:asText" as const;
/**
* Confidence words the fork's default prompt uses. A tag ending in one of
* these is read as category + confidence; anything else is all category,
* since an operator's own categories may contain underscores.
*/
const CONFIDENCES = new Set(["LOW", "MEDIUM", "HIGH"]);
export interface LlmOpinion {
/** The tag as the server wrote it, e.g. `LLM_UNSOLICITED_HIGH`. */
tag: string;
/** Readable category, e.g. `Unsolicited`. */
category: string;
/** Readable confidence, e.g. `High`, where the tag carried one. */
confidence: string | null;
/** The model's own explanation, as plain text, where there is one. */
explanation: string | null;
}
/** `UNSOLICITED_BULK` -> `Unsolicited bulk`. */
function readable(words: string[]): string {
const s = words.join(" ").toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Headers arrive folded, so tabs and newlines are whitespace like any other. */
function flatten(v: string | null | undefined): string {
return (v ?? "").replace(/\s+/g, " ").trim();
}
export function parseLlmOpinion(raw: string | null | undefined): LlmOpinion | null {
const s = flatten(raw);
const m = /^(LLM_[A-Z0-9_]+)(?:\s+(.*))?$/.exec(s);
if (!m) return null;
const tag = m[1]!;
const parts = tag.slice("LLM_".length).split("_").filter(Boolean);
if (parts.length === 0) return null;
let confidence: string | null = null;
if (parts.length > 1 && CONFIDENCES.has(parts[parts.length - 1]!)) {
confidence = readable([parts.pop()!]);
}
let explanation: string | null = null;
const rest = (m[2] ?? "").trim();
if (rest) {
// The server wraps the explanation in one pair of parentheses.
const inner = rest.startsWith("(") && rest.endsWith(")") ? rest.slice(1, -1).trim() : rest;
explanation = inner || null;
}
return { tag, category: readable(parts), confidence, explanation };
}
/** The opinion on a message, if the server recorded one. */
export function llmOpinion(email: { [LLM_HEADER_PROP]?: string | null }): LlmOpinion | null {
return parseLlmOpinion(email[LLM_HEADER_PROP]);
}
+65
View File
@@ -0,0 +1,65 @@
import { unproxiedImageUrl } from "@/lib/text/html";
import type { ImagePolicy } from "@/store/settings";
/**
* Whether a message's remote images may be fetched.
*
* The reader's decision, in one place, because the composer has to make the
* same one. Quoting a message into a reply renders it again — and a quote that
* fetched what the reader had declined would report the message read, and the
* address live, to whoever was counting. The tracking pixel does not care
* which window it loaded in.
*/
export function remoteImagesAllowed(opts: {
from: string | null | undefined;
policy: ImagePolicy;
trusted: string[];
inContacts: boolean;
/** The reader pressed "Show images" on this message. */
shown: boolean;
}): boolean {
if (opts.shown || opts.policy === "always") return true;
if (opts.trusted.includes((opts.from ?? "").toLowerCase())) return true;
return opts.policy === "contacts" && opts.inContacts;
}
/**
* Point proxied images back at their own addresses, on the way out.
*
* Reading a message fetches its remote images through this server, so the
* sender learns nothing about the reader. Those URLs belong to this
* deployment, so a quote that kept them would reach the recipient as images
* only this server can serve -- broken for them, and a beacon back here for
* anyone who could load them (#412).
*/
export function unproxyImages(html: string): string {
if (!html.includes("/api/image?url=")) return html;
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of Array.from(doc.querySelectorAll("img[src]"))) {
const real = unproxiedImageUrl(img.getAttribute("src") ?? "");
if (real) img.setAttribute("src", real);
}
return doc.body.innerHTML;
}
/**
* Put back the addresses of images that were blocked when the message was
* quoted, on the way out.
*
* Blocking keeps the original URL on the element (`data-ihm-remote`), so
* nothing was lost by not fetching it. The copy that leaves here should be the
* quote as its sender wrote it: the recipient's client decides for itself
* whether to load those images, the same as it would have with any other
* client's reply.
*/
export function restoreBlockedImages(html: string): string {
if (!html.includes("data-ihm-blocked")) return html;
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of Array.from(doc.querySelectorAll("img[data-ihm-blocked]"))) {
const url = img.getAttribute("data-ihm-remote");
if (url) img.setAttribute("src", url);
img.removeAttribute("data-ihm-blocked");
img.removeAttribute("data-ihm-remote");
}
return doc.body.innerHTML;
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf } from "../folderOrder"; import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf, treeOrder } from "../folderOrder";
import type { Id, Mailbox } from "@/jmap/types"; import type { Id, Mailbox } from "@/jmap/types";
const RIGHTS = { mayRename: true, mayCreateChild: true } as Mailbox["myRights"]; const RIGHTS = { mayRename: true, mayCreateChild: true } as Mailbox["myRights"];
@@ -119,3 +119,23 @@ describe("neighbour", () => {
expect(neighbour(hidden, "alpha", "up", (m) => m.isSubscribed)).toEqual({ targetId: "junk", placement: "before" }); expect(neighbour(hidden, "alpha", "up", (m) => m.isSubscribed)).toEqual({ targetId: "junk", placement: "before" });
}); });
}); });
describe("treeOrder", () => {
const ids = (all: Record<Id, Mailbox>) => treeOrder(all).map((m) => m.id);
it("lists the tree the way the sidebar does, each folder followed by its subfolders", () => {
expect(ids(fresh)).toEqual(["inbox", "drafts", "sent", "junk", "trash", "alpha", "work", "clients", "zeta"]);
});
it("follows a saved order rather than A–Z", () => {
// #1 on GitLab: the move-to picker kept the old order after the sidebar changed.
const ordered = apply(fresh, { zeta: { sortOrder: 10 }, sent: { sortOrder: 20 }, alpha: { sortOrder: 30 }, drafts: { sortOrder: 40 }, junk: { sortOrder: 50 }, trash: { sortOrder: 60 }, work: { sortOrder: 70 } });
expect(ids(ordered)).toEqual(["inbox", "zeta", "sent", "alpha", "drafts", "junk", "trash", "work", "clients"]);
});
it("still lists a folder the walk from the top can't reach", () => {
const looped = apply(fresh, { work: { parentId: "clients" } });
expect(ids(looped)).toHaveLength(Object.keys(looped).length);
expect(ids(looped)).toEqual(expect.arrayContaining(["work", "clients"]));
});
});
+31
View File
@@ -25,6 +25,37 @@ function roleRank(m: Mailbox): number {
return m.role && m.role in ROLE_ORDER ? ROLE_ORDER[m.role]! : Number.MAX_SAFE_INTEGER; return m.role && m.role in ROLE_ORDER ? ROLE_ORDER[m.role]! : Number.MAX_SAFE_INTEGER;
} }
/**
* Every folder, parents before their children and siblings in
* `compareFolders` order: the sidebar's order with every folder expanded.
* Lists that show all folders at once, like the move-to picker, use this so a
* folder sits where the user dragged it rather than where A–Z would put it.
*
* A folder the walk from the top never reaches (a parent loop the server
* should not allow) is appended rather than dropped, so it can still be
* picked.
*/
export function treeOrder(mailboxes: Record<Id, Mailbox>): Mailbox[] {
const byParent = new Map<Id | null, Mailbox[]>();
for (const m of Object.values(mailboxes)) {
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
byParent.set(p, [...(byParent.get(p) ?? []), m]);
}
for (const list of byParent.values()) list.sort(compareFolders);
const out: Mailbox[] = [];
const seen = new Set<Id>();
const walk = (parent: Id | null) => {
for (const m of byParent.get(parent) ?? []) {
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push(m);
walk(m.id);
}
};
walk(null);
return out.concat(Object.values(mailboxes).filter((m) => !seen.has(m.id)).sort(compareFolders));
}
/** Every folder under `parentId` (null: the top level), in list order. */ /** Every folder under `parentId` (null: the top level), in list order. */
export function siblingsOf(mailboxes: Record<Id, Mailbox>, parentId: Id | null): Mailbox[] { export function siblingsOf(mailboxes: Record<Id, Mailbox>, parentId: Id | null): Mailbox[] {
return Object.values(mailboxes) return Object.values(mailboxes)
+5 -5
View File
@@ -1,6 +1,6 @@
import { withBase } from "../basePath"; import { brandImage } from "@/lib/brand";
let baseTitle = "INBUXA"; let baseTitle = "inbuxa";
let faviconCanvas: HTMLCanvasElement | null = null; let faviconCanvas: HTMLCanvasElement | null = null;
let baseFavicon: HTMLImageElement | null = null; let baseFavicon: HTMLImageElement | null = null;
@@ -40,13 +40,13 @@ export function setUnreadBadge(count: number): void {
if (!link) return; if (!link) return;
if (!baseFavicon) { if (!baseFavicon) {
baseFavicon = new Image(); baseFavicon = new Image();
baseFavicon.src = withBase("/img/favicon-64.png"); baseFavicon.src = brandImage("/img/favicon-64.png");
baseFavicon.onload = () => setUnreadBadge(count); baseFavicon.onload = () => setUnreadBadge(count);
return; return;
} }
if (!baseFavicon.complete) return; if (!baseFavicon.complete) return;
if (count <= 0) { if (count <= 0) {
link.href = withBase("/img/favicon-64.png"); link.href = brandImage("/img/favicon-64.png");
return; return;
} }
faviconCanvas ??= document.createElement("canvas"); faviconCanvas ??= document.createElement("canvas");
@@ -95,7 +95,7 @@ export function showNotification(title: string, opts: NotificationOptions & { on
if (!("Notification" in window) || Notification.permission !== "granted") return; if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return; if (document.visibilityState === "visible" && document.hasFocus()) return;
const { onClick, ...options } = opts; const { onClick, ...options } = opts;
const full = { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...options }; const full = { icon: brandImage("/img/icon-192.png"), badge: brandImage("/img/favicon-64.png"), ...options };
const viaWorker = navigator.serviceWorker?.controller ? navigator.serviceWorker.ready : null; const viaWorker = navigator.serviceWorker?.controller ? navigator.serviceWorker.ready : null;
if (viaWorker) { if (viaWorker) {
void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined); void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined);
+3 -2
View File
@@ -10,7 +10,7 @@ import { withBase } from "../basePath";
import { SW_CACHE_NAME } from "../sw/swCache"; import { SW_CACHE_NAME } from "../sw/swCache";
import { isDeviceTrusted } from "@/lib/storage"; import { isDeviceTrusted } from "@/lib/storage";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useMail } from "@/store/mail"; import { ownInboxId } from "@/store/mail";
import { import {
applicationServerKey, applicationServerKey,
createSubscription, createSubscription,
@@ -153,7 +153,8 @@ async function registerThisBrowser(key: string): Promise<void> {
} }
if (mine.length) await destroySubscriptions(mine.map((s) => s.id)); if (mine.length) await destroySubscriptions(mine.map((s) => s.id));
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), useMail.getState().roleId("inbox")); // 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());
try { try {
await createSubscription(payload); await createSubscription(payload);
} catch (err) { } catch (err) {
+28 -10
View File
@@ -34,6 +34,9 @@ let inFlight: Promise<void> | null = null;
/** Nothing is pushed before the first load has settled, or we would race it. */ /** Nothing is pushed before the first load has settled, or we would race it. */
let armed = false; let armed = false;
let loadedFor: string | null = null; let loadedFor: string | null = null;
let loading: Promise<void> | null = null;
/** Bumped by every new load and every sign-out; a load checks it is still the latest. */
let generation = 0;
let listenersBound = false; let listenersBound = false;
export function settingsSyncAvailable(): boolean { export function settingsSyncAvailable(): boolean {
@@ -64,21 +67,34 @@ export async function loadRemoteSettings(): Promise<Record<string, unknown> | nu
} }
/** /**
* Has this account's settings file already been read on this page load? * Load this account's settings, once per page load, however many times the
* caller mounts.
* *
* Claims the account as a side effect, so two callers cannot both start a * The subtree that does the reading is keyed on the language version, so it
* read. The subtree that does the reading is keyed on the language version * is remounted whenever the language changes -- including when the settings
* and so is deliberately remounted whenever somebody picks a language; * file itself picks one. Tying the load to a mount meant that remount canceled
* without this the remount re-reads a file written before the change and * it halfway: the new mount saw the account already claimed and skipped the
* applies it, putting the old language back. * load, and nothing armed the pushes, so every later change was silently
* dropped (Gitea issue #23). Now the load belongs to the account, not the
* mount: every mount waits on the same promise, and the load runs to the end.
* *
* Re-reading on a remount is still wrong -- it would apply a file written
* before the change and undo it -- which is why it is shared, not repeated.
*
* `isCurrent` turns false once the session that started the load signs out,
* so a load overtaken by a sign-out stops instead of applying stale settings.
* Cleared by `stopSettingsSync`, so signing out and back in reads again. * Cleared by `stopSettingsSync`, so signing out and back in reads again.
*/ */
export function settingsAlreadyLoadedFor(accountId: string | null | undefined): boolean { export function loadSettingsOnce(
if (!accountId) return true; accountId: string | null | undefined,
if (loadedFor === accountId) return true; load: (isCurrent: () => boolean) => Promise<void>,
): Promise<void> {
if (!accountId) return Promise.resolve();
if (loadedFor === accountId && loading) return loading;
loadedFor = accountId; loadedFor = accountId;
return false; const mine = ++generation;
loading = load(() => generation === mine);
return loading;
} }
/** Allow pushes. Called once the first load has settled, either way. */ /** Allow pushes. Called once the first load has settled, either way. */
@@ -93,6 +109,8 @@ export function armSettingsSync(): void {
export function stopSettingsSync(): void { export function stopSettingsSync(): void {
armed = false; armed = false;
loadedFor = null; loadedFor = null;
loading = null;
generation++;
pending = null; pending = null;
if (timer !== null) { if (timer !== null) {
window.clearTimeout(timer); window.clearTimeout(timer);
+1 -1
View File
@@ -8,4 +8,4 @@
* ihasmail-inbuxa: INBUXA runs a modified ihasmail, so the offer is INBUXA's * ihasmail-inbuxa: INBUXA runs a modified ihasmail, so the offer is INBUXA's
* fork and not the project it came from. * fork and not the project it came from.
*/ */
export const DEFAULT_SOURCE_URL = "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa"; export const DEFAULT_SOURCE_URL = "https://git.coffeylabs.org/inbuxa/inbuxa-webmail";
+13
View File
@@ -387,3 +387,16 @@ describe("the containment that mail CSS cannot override", () => {
expect(rule).toMatch(/contain\s*:\s*layout/); expect(rule).toMatch(/contain\s*:\s*layout/);
}); });
}); });
describe("mail wider than the reading pane", () => {
/*
* The root keeps `contain: content`, which clips what overflows, so without
* a scroll the right side of a min-width table was simply gone, worst on a
* phone (Gitea issue #24). jsdom does no layout, so this pins the rule.
*/
it("scrolls sideways instead of being cut off", () => {
const root = EMAIL_BASE_CSS.match(/\.ihm-email-root \{([^}]*)\}/)?.[1] ?? "";
expect(root).toMatch(/contain:\s*content/);
expect(root).toMatch(/overflow-x:\s*auto/);
});
});
+22 -2
View File
@@ -1,5 +1,5 @@
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { withBase } from "@/lib/basePath"; import { BASE_PATH, withBase } from "@/lib/basePath";
export interface SanitizeOptions { export interface SanitizeOptions {
/** Map of Content-ID (without angle brackets) → URL for inline images. */ /** Map of Content-ID (without angle brackets) → URL for inline images. */
@@ -158,6 +158,23 @@ export function proxiedImageUrl(url: string): string {
return withBase(`/api/image?url=${encodeURIComponent(url)}`); return withBase(`/api/image?url=${encodeURIComponent(url)}`);
} }
/**
* The address a proxied image really points at, or null if this is not one.
*
* A proxied URL is this server's, so it is right for reading a message and
* wrong for sending one: a quote left this way would hand the recipient
* images that only load from inside this deployment (#412).
*/
export function unproxiedImageUrl(src: string): string | null {
const path = `${BASE_PATH}/api/image?url=`;
if (!src.startsWith(path)) return null;
try {
return decodeURIComponent(src.slice(path.length)) || null;
} catch {
return null; // Malformed escape: leave it alone rather than mangle it.
}
}
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult { export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
ensureHooks(); ensureHooks();
let bodyStyle = ""; let bodyStyle = "";
@@ -270,7 +287,10 @@ export function sanitizeEditorHtml(input: string): string {
export const EMAIL_BASE_CSS = ` export const EMAIL_BASE_CSS = `
:host { display:block; color-scheme: light; } :host { display:block; color-scheme: light; }
:host(.themed) { color-scheme: inherit; } :host(.themed) { color-scheme: inherit; }
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; } /* contain: content clips what overflows, so mail built wider than the pane --
a min-width table, a nowrap cell -- was cut off with no way to reach the
rest. It scrolls sideways instead (Gitea issue #24). */
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; overflow-x:auto; overflow-y:hidden; }
.ihm-email-root img { max-width:100%; height:auto; } .ihm-email-root img { max-width:100%; height:auto; }
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; } .ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
.ihm-email-root table { max-width:100%; } .ihm-email-root table { max-width:100%; }
+43 -4
View File
@@ -147,8 +147,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Die Zahlen, die Ihre Rolle sehen darf, so wie der Server sie meldet.", "The numbers your role can see, as the server reports them.": "Die Zahlen, die Ihre Rolle sehen darf, so wie der Server sie meldet.",
"Nothing to show": "Nichts anzuzeigen", "Nothing to show": "Nichts anzuzeigen",
"Could not be loaded": "Konnte nicht geladen werden", "Could not be loaded": "Konnte nicht geladen werden",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in INBUXA Admin.", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in inbuxa Admin.",
"Open INBUXA Admin": "INBUXA Admin öffnen", "Open inbuxa Admin": "inbuxa Admin öffnen",
"Default group role": "Standardrolle für Gruppen", "Default group role": "Standardrolle für Gruppen",
"A group needs an address.": "Eine Gruppe braucht eine Adresse.", "A group needs an address.": "Eine Gruppe braucht eine Adresse.",
"New group": "Neue Gruppe", "New group": "Neue Gruppe",
@@ -220,7 +220,7 @@ export const catalog: Catalog = {
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Der Mailserver vergibt diese Rolle standardmäßig an {kinds}. Eine Änderung betrifft alle, die sie auf diesem Weg haben.", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Der Mailserver vergibt diese Rolle standardmäßig an {kinds}. Eine Änderung betrifft alle, die sie auf diesem Weg haben.",
"Builds on": "Baut auf", "Builds on": "Baut auf",
"Permissions": "Berechtigungen", "Permissions": "Berechtigungen",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Der Mailserver vergibt diese Rolle standardmäßig, daher kann sie nicht gelöscht werden. Ändern Sie zuerst die Standardwerte in INBUXA Admin.", "The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Der Mailserver vergibt diese Rolle standardmäßig, daher kann sie nicht gelöscht werden. Ändern Sie zuerst die Standardwerte in inbuxa Admin.",
"This role carries permissions yours doesn't.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat.", "This role carries permissions yours doesn't.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat.",
"Create role": "Rolle anlegen", "Create role": "Rolle anlegen",
"builds on this one": "baut auf dieser auf", "builds on this one": "baut auf dieser auf",
@@ -812,6 +812,9 @@ export const catalog: Catalog = {
"Text size": "Schriftgröße", "Text size": "Schriftgröße",
"Font size": "Schriftgröße", "Font size": "Schriftgröße",
"Small": "Klein", "Small": "Klein",
"Original size": "Originalgröße",
"Drag to resize": "Zum Ändern der Größe ziehen",
"Image size": "Bildgröße",
"Medium": "Mittel", "Medium": "Mittel",
"Large": "Groß", "Large": "Groß",
"Huge": "Sehr groß", "Huge": "Sehr groß",
@@ -1059,7 +1062,6 @@ export const catalog: Catalog = {
"Select a conversation to read it here · Press {key} for shortcuts": "Wählen Sie eine Konversation, um sie hier zu lesen · {key} für Tastenkürzel", "Select a conversation to read it here · Press {key} for shortcuts": "Wählen Sie eine Konversation, um sie hier zu lesen · {key} für Tastenkürzel",
"Select a message to read it here · Press {key} for shortcuts": "Wählen Sie eine Nachricht, um sie hier zu lesen · {key} für Tastenkürzel", "Select a message to read it here · Press {key} for shortcuts": "Wählen Sie eine Nachricht, um sie hier zu lesen · {key} für Tastenkürzel",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tipp: Drücken Sie {key} auf einer Konversation, um Labels zu vergeben. Suchen Sie mit {operator}.", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tipp: Drücken Sie {key} auf einer Konversation, um Labels zu vergeben. Suchen Sie mit {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Eine schnelle, freundliche Open-Source-Webmail für {server}, auf JMAP aufgebaut.",
"Defaults for the calendar views and new events.": "Vorgaben für die Kalenderansichten und neue Termine.", "Defaults for the calendar views and new events.": "Vorgaben für die Kalenderansichten und neue Termine.",
"Replies will go to this address instead of the From address": "Antworten gehen an diese Adresse statt an die Absenderadresse", "Replies will go to this address instead of the From address": "Antworten gehen an diese Adresse statt an die Absenderadresse",
"Replies to mail sent from this identity go here instead of the From address.": "Antworten auf Nachrichten von dieser Identität gehen hierhin statt an die Absenderadresse.", "Replies to mail sent from this identity go here instead of the From address.": "Antworten auf Nachrichten von dieser Identität gehen hierhin statt an die Absenderadresse.",
@@ -1407,6 +1409,8 @@ export const catalog: Catalog = {
"Collapse all": "Alle einklappen", "Collapse all": "Alle einklappen",
"Expand all": "Alle ausklappen", "Expand all": "Alle ausklappen",
"Send now instead": "Stattdessen jetzt senden", "Send now instead": "Stattdessen jetzt senden",
"This message is rich text": "Diese Nachricht ist formatierter Text",
"This message is plain text": "Diese Nachricht ist Nur-Text",
"Switch to plain text": "Zu Nur-Text wechseln", "Switch to plain text": "Zu Nur-Text wechseln",
"Switch to rich text": "Zu formatiertem Text wechseln", "Switch to rich text": "Zu formatiertem Text wechseln",
"{used} of {total} used": "{used} von {total} belegt", "{used} of {total} used": "{used} von {total} belegt",
@@ -1740,6 +1744,41 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben", "To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben",
"Turn off legacy protocols": "Ältere Mailprotokolle ausschalten", "Turn off legacy protocols": "Ältere Mailprotokolle ausschalten",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet. Nur {app} und JMAP-Apps können sich anmelden.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet. Nur {app} und JMAP-Apps können sich anmelden.",
// ── About: inbuxa, the suite ────────────────────────────────────
"{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} ist eine komplette Mail-Suite: ein Mailserver, die Konsole, mit der er verwaltet wird, und dieses Webmail – jedes ein eigenes Programm, gemeinsam installiert und frei unter der AGPL.",
"The suite": "Die Suite",
"Administration console": "Verwaltungskonsole",
"Webmail": "Webmail",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Webmail-Knoten",
"Mail server node": "Mailserver-Knoten",
"{host} does not resolve": "{host} lässt sich nicht auflösen",
"unavailable": "nicht verfügbar",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Einschätzung des Sprachmodells",
"One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Ihre Organisation hat {protocols} für Mail-Apps ausgeschaltet. Mail-Apps, die das nutzen, können sich nicht mit diesem Konto verbinden; andere schon.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Einige ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Einige sind für {tenant} aus: {protocols}. In der Verwaltungskonsole lassen sie sich einzeln ein- und ausschalten.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Löschen, Sperren und rechtliche Aufbewahrungspflichten werden in der Verwaltungskonsole erledigt, die den Grund festhält und bewahrt, was eine Aufbewahrungspflicht umfasst.",
"Open in the console": "In der Konsole öffnen",
// inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "Sie haben keinen Zugriff mehr auf {name}. Zurück zu Ihren eigenen E-Mails.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Sie können nicht als {name} senden. Das Konto wurde Ihnen zum Lesen übergeben, nicht zum Senden.",
"This account was handed to you to read. Nothing in it can be changed.": "Dieses Konto wurde Ihnen zum Lesen übergeben. Darin kann nichts geändert werden.",
"You can file and move mail in this account, but not delete it.": "Sie können E-Mails in diesem Konto ablegen und verschieben, aber nicht löschen.",
"Read only": "Nur lesen",
"Read and organize": "Lesen und ordnen",
"Full access": "Voller Zugriff",
"Locked account:": "Gesperrtes Konto:",
"You can send as this account": "Sie können als dieses Konto senden",
"Back to my mail": "Zurück zu meinen E-Mails",
"Send or close the message you're writing first.": "Senden oder schließen Sie zuerst die Nachricht, die Sie gerade schreiben.",
"Mail to show": "E-Mails anzeigen von",
"My mail": "Meine E-Mails",
"Locked account": "Gesperrtes Konto",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+43 -4
View File
@@ -139,8 +139,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Las cifras que su rol puede ver, tal como las informa el servidor.", "The numbers your role can see, as the server reports them.": "Las cifras que su rol puede ver, tal como las informa el servidor.",
"Nothing to show": "Nada que mostrar", "Nothing to show": "Nada que mostrar",
"Could not be loaded": "No se pudo cargar", "Could not be loaded": "No se pudo cargar",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Las métricas detalladas, la cola de entrega, los registros y los ajustes del servidor están en INBUXA Admin.", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Las métricas detalladas, la cola de entrega, los registros y los ajustes del servidor están en inbuxa Admin.",
"Open INBUXA Admin": "Abrir INBUXA Admin", "Open inbuxa Admin": "Abrir inbuxa Admin",
"Default group role": "Rol de grupo predeterminado", "Default group role": "Rol de grupo predeterminado",
"A group needs an address.": "Un grupo necesita una dirección.", "A group needs an address.": "Un grupo necesita una dirección.",
"New group": "Nuevo grupo", "New group": "Nuevo grupo",
@@ -212,7 +212,7 @@ export const catalog: Catalog = {
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "El servidor de correo asigna este rol de forma predeterminada a {kinds}. Un cambio aquí afecta a todos los que lo tienen así.", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "El servidor de correo asigna este rol de forma predeterminada a {kinds}. Un cambio aquí afecta a todos los que lo tienen así.",
"Builds on": "Se basa en", "Builds on": "Se basa en",
"Permissions": "Permisos", "Permissions": "Permisos",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "El servidor de correo asigna este rol de forma predeterminada, así que no se puede eliminar. Cambie primero los valores predeterminados en INBUXA Admin.", "The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "El servidor de correo asigna este rol de forma predeterminada, así que no se puede eliminar. Cambie primero los valores predeterminados en inbuxa Admin.",
"This role carries permissions yours doesn't.": "Este rol tiene permisos que el suyo no tiene.", "This role carries permissions yours doesn't.": "Este rol tiene permisos que el suyo no tiene.",
"Create role": "Crear rol", "Create role": "Crear rol",
"builds on this one": "se basa en este", "builds on this one": "se basa en este",
@@ -808,6 +808,9 @@ export const catalog: Catalog = {
"Text size": "Tamaño del texto", "Text size": "Tamaño del texto",
"Font size": "Tamaño de letra", "Font size": "Tamaño de letra",
"Small": "Pequeño", "Small": "Pequeño",
"Original size": "Tamaño original",
"Drag to resize": "Arrastra para cambiar el tamaño",
"Image size": "Tamaño de la imagen",
"Medium": "Mediano", "Medium": "Mediano",
"Large": "Grande", "Large": "Grande",
"Huge": "Muy grande", "Huge": "Muy grande",
@@ -1121,7 +1124,6 @@ export const catalog: Catalog = {
"Select a conversation to read it here · Press {key} for shortcuts": "Seleccione una conversación para leerla aquí · {key} para los atajos", "Select a conversation to read it here · Press {key} for shortcuts": "Seleccione una conversación para leerla aquí · {key} para los atajos",
"Select a message to read it here · Press {key} for shortcuts": "Seleccione un mensaje para leerlo aquí · {key} para los atajos", "Select a message to read it here · Press {key} for shortcuts": "Seleccione un mensaje para leerlo aquí · {key} para los atajos",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Consejo: pulse {key} sobre una conversación para aplicar etiquetas. Busque con {operator}.", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Consejo: pulse {key} sobre una conversación para aplicar etiquetas. Busque con {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rápido y agradable para {server}, construido sobre JMAP.",
"Defaults for the calendar views and new events.": "Valores predeterminados de las vistas del calendario y de los eventos nuevos.", "Defaults for the calendar views and new events.": "Valores predeterminados de las vistas del calendario y de los eventos nuevos.",
"Replies will go to this address instead of the From address": "Las respuestas irán a esta dirección en lugar de a la del remitente", "Replies will go to this address instead of the From address": "Las respuestas irán a esta dirección en lugar de a la del remitente",
"Replies to mail sent from this identity go here instead of the From address.": "Las respuestas al correo enviado desde esta identidad llegan aquí en lugar de a la dirección del remitente.", "Replies to mail sent from this identity go here instead of the From address.": "Las respuestas al correo enviado desde esta identidad llegan aquí en lugar de a la dirección del remitente.",
@@ -1380,6 +1382,8 @@ export const catalog: Catalog = {
"Collapse all": "Contraer todo", "Collapse all": "Contraer todo",
"Expand all": "Expandir todo", "Expand all": "Expandir todo",
"Send now instead": "Enviar ahora, sin programar", "Send now instead": "Enviar ahora, sin programar",
"This message is rich text": "Este mensaje es texto enriquecido",
"This message is plain text": "Este mensaje es texto sin formato",
"Switch to plain text": "Cambiar a texto sin formato", "Switch to plain text": "Cambiar a texto sin formato",
"Switch to rich text": "Cambiar a texto enriquecido", "Switch to rich text": "Cambiar a texto enriquecido",
"{used} of {total} used": "{used} de {total} usados", "{used} of {total} used": "{used} de {total} usados",
@@ -1713,6 +1717,41 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Para confirmar, escriba {phrase}", "To confirm, type {phrase}": "Para confirmar, escriba {phrase}",
"Turn off legacy protocols": "Desactivar los protocolos de correo heredados", "Turn off legacy protocols": "Desactivar los protocolos de correo heredados",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Los protocolos de correo heredados están desactivados para su organización. Solo {app} y las aplicaciones JMAP pueden iniciar sesión.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Los protocolos de correo heredados están desactivados para su organización. Solo {app} y las aplicaciones JMAP pueden iniciar sesión.",
// ── About: inbuxa, the suite ────────────────────────────────────
"{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} es una suite de correo completa: un servidor de correo, la consola que lo administra y este webmail, cada uno un programa propio, instalados juntos y libres bajo la AGPL.",
"The suite": "La suite",
"Administration console": "Consola de administración",
"Webmail": "Webmail",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Nodo del webmail",
"Mail server node": "Nodo del servidor de correo",
"{host} does not resolve": "{host} no se resuelve",
"unavailable": "no disponible",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Opinión del modelo de lenguaje",
"One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Su organización ha desactivado {protocols} para las aplicaciones de correo. Las que lo usan no pueden conectarse a esta cuenta; las demás sí.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Algunos protocolos de correo heredados están desactivados para su organización: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Algunos están desactivados para {tenant}: {protocols}. Actívelos o desactívelos uno a uno en la consola de administración.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Eliminar, bloquear y las retenciones legales se gestionan en la consola de administración, que registra el motivo y conserva lo que cubre una retención.",
"Open in the console": "Abrir en la consola",
// inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "Ya no tiene acceso a {name}. Vuelve a su propio correo.",
"You can't send from {name}. It was handed to you to read, not to send as.": "No puede enviar desde {name}. Se le entregó para leerla, no para enviar en su nombre.",
"This account was handed to you to read. Nothing in it can be changed.": "Esta cuenta se le entregó para leerla. No se puede cambiar nada en ella.",
"You can file and move mail in this account, but not delete it.": "Puede archivar y mover el correo de esta cuenta, pero no eliminarlo.",
"Read only": "Solo lectura",
"Read and organize": "Leer y organizar",
"Full access": "Acceso completo",
"Locked account:": "Cuenta bloqueada:",
"You can send as this account": "Puede enviar como esta cuenta",
"Back to my mail": "Volver a mi correo",
"Send or close the message you're writing first.": "Envíe o cierre primero el mensaje que está escribiendo.",
"Mail to show": "Correo que mostrar",
"My mail": "Mi correo",
"Locked account": "Cuenta bloqueada",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+43 -4
View File
@@ -144,8 +144,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Les chiffres que votre rôle permet de voir, tels que le serveur les indique.", "The numbers your role can see, as the server reports them.": "Les chiffres que votre rôle permet de voir, tels que le serveur les indique.",
"Nothing to show": "Rien à afficher", "Nothing to show": "Rien à afficher",
"Could not be loaded": "Chargement impossible", "Could not be loaded": "Chargement impossible",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans INBUXA Admin.", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans inbuxa Admin.",
"Open INBUXA Admin": "Ouvrir INBUXA Admin", "Open inbuxa Admin": "Ouvrir inbuxa Admin",
"Default group role": "Rôle de groupe par défaut", "Default group role": "Rôle de groupe par défaut",
"A group needs an address.": "Un groupe a besoin d’une adresse.", "A group needs an address.": "Un groupe a besoin d’une adresse.",
"New group": "Nouveau groupe", "New group": "Nouveau groupe",
@@ -217,7 +217,7 @@ export const catalog: Catalog = {
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Le serveur de messagerie attribue ce rôle par défaut à {kinds}. Une modification ici s’applique à tous ceux qui l’ont ainsi.", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Le serveur de messagerie attribue ce rôle par défaut à {kinds}. Une modification ici s’applique à tous ceux qui l’ont ainsi.",
"Builds on": "S’appuie sur", "Builds on": "S’appuie sur",
"Permissions": "Autorisations", "Permissions": "Autorisations",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Le serveur de messagerie attribue ce rôle par défaut : il ne peut donc pas être supprimé. Modifiez d’abord les valeurs par défaut dans INBUXA Admin.", "The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Le serveur de messagerie attribue ce rôle par défaut : il ne peut donc pas être supprimé. Modifiez d’abord les valeurs par défaut dans inbuxa Admin.",
"This role carries permissions yours doesn't.": "Ce rôle comporte des autorisations que le vôtre n’a pas.", "This role carries permissions yours doesn't.": "Ce rôle comporte des autorisations que le vôtre n’a pas.",
"Create role": "Créer le rôle", "Create role": "Créer le rôle",
"builds on this one": "s’appuie sur celui-ci", "builds on this one": "s’appuie sur celui-ci",
@@ -814,6 +814,9 @@ export const catalog: Catalog = {
"Text size": "Taille du texte", "Text size": "Taille du texte",
"Font size": "Taille de police", "Font size": "Taille de police",
"Small": "Petite", "Small": "Petite",
"Original size": "Taille d’origine",
"Drag to resize": "Faites glisser pour redimensionner",
"Image size": "Taille de l’image",
"Medium": "Moyenne", "Medium": "Moyenne",
"Large": "Grande", "Large": "Grande",
"Huge": "Très grande", "Huge": "Très grande",
@@ -1126,7 +1129,6 @@ export const catalog: Catalog = {
"Select a conversation to read it here · Press {key} for shortcuts": "Sélectionnez une conversation pour la lire ici · {key} pour les raccourcis", "Select a conversation to read it here · Press {key} for shortcuts": "Sélectionnez une conversation pour la lire ici · {key} pour les raccourcis",
"Select a message to read it here · Press {key} for shortcuts": "Sélectionnez un message pour le lire ici · {key} pour les raccourcis", "Select a message to read it here · Press {key} for shortcuts": "Sélectionnez un message pour le lire ici · {key} pour les raccourcis",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Astuce : appuyez sur {key} sur une conversation pour appliquer des libellés. Recherchez avec {operator}.", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Astuce : appuyez sur {key} sur une conversation pour appliquer des libellés. Recherchez avec {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rapide et agréable pour {server}, bâti sur JMAP.",
"Defaults for the calendar views and new events.": "Valeurs par défaut des vues d'agenda et des nouveaux événements.", "Defaults for the calendar views and new events.": "Valeurs par défaut des vues d'agenda et des nouveaux événements.",
"Replies will go to this address instead of the From address": "Les réponses iront à cette adresse plutôt qu'à l'adresse d'expédition", "Replies will go to this address instead of the From address": "Les réponses iront à cette adresse plutôt qu'à l'adresse d'expédition",
"Replies to mail sent from this identity go here instead of the From address.": "Les réponses aux messages envoyés depuis cette identité arrivent ici plutôt qu'à l'adresse d'expédition.", "Replies to mail sent from this identity go here instead of the From address.": "Les réponses aux messages envoyés depuis cette identité arrivent ici plutôt qu'à l'adresse d'expédition.",
@@ -1385,6 +1387,8 @@ export const catalog: Catalog = {
"Collapse all": "Tout réduire", "Collapse all": "Tout réduire",
"Expand all": "Tout développer", "Expand all": "Tout développer",
"Send now instead": "Envoyer tout de suite", "Send now instead": "Envoyer tout de suite",
"This message is rich text": "Ce message est en texte enrichi",
"This message is plain text": "Ce message est en texte brut",
"Switch to plain text": "Passer en texte brut", "Switch to plain text": "Passer en texte brut",
"Switch to rich text": "Passer en texte enrichi", "Switch to rich text": "Passer en texte enrichi",
"{used} of {total} used": "{used} sur {total} utilisés", "{used} of {total} used": "{used} sur {total} utilisés",
@@ -1718,6 +1722,41 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}", "To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}",
"Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques", "Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Les protocoles de messagerie historiques sont désactivés pour votre organisation. Seuls {app} et les applications JMAP peuvent se connecter.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Les protocoles de messagerie historiques sont désactivés pour votre organisation. Seuls {app} et les applications JMAP peuvent se connecter.",
// ── About: inbuxa, the suite ────────────────────────────────────
"{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} est une suite de messagerie complète : un serveur de messagerie, la console qui l’administre et ce webmail, chacun étant un programme à part entière, installés ensemble et libres sous licence AGPL.",
"The suite": "La suite",
"Administration console": "Console d’administration",
"Webmail": "Webmail",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Nœud du webmail",
"Mail server node": "Nœud du serveur de messagerie",
"{host} does not resolve": "{host} ne se résout pas",
"unavailable": "indisponible",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Avis du modèle de langage",
"One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Votre organisation a désactivé {protocols} pour les applications de messagerie. Celles qui l’utilisent ne peuvent pas se connecter à ce compte ; les autres le peuvent toujours.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Certains protocoles de messagerie historiques sont désactivés pour votre organisation : {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Certains sont désactivés pour {tenant} : {protocols}. Activez-les ou désactivez-les un par un dans la console d’administration.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "La suppression, le verrouillage et les conservations légales se font dans la console d’administration, qui enregistre le motif et conserve ce qu’une conservation couvre.",
"Open in the console": "Ouvrir dans la console",
// inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "Vous n’avez plus accès à {name}. Retour à votre propre courrier.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Vous ne pouvez pas envoyer depuis {name}. Ce compte vous a été confié pour le lire, pas pour envoyer en son nom.",
"This account was handed to you to read. Nothing in it can be changed.": "Ce compte vous a été confié pour le lire. Rien ne peut y être modifié.",
"You can file and move mail in this account, but not delete it.": "Vous pouvez classer et déplacer le courrier de ce compte, mais pas le supprimer.",
"Read only": "Lecture seule",
"Read and organize": "Lecture et classement",
"Full access": "Accès complet",
"Locked account:": "Compte verrouillé :",
"You can send as this account": "Vous pouvez envoyer au nom de ce compte",
"Back to my mail": "Retour à mon courrier",
"Send or close the message you're writing first.": "Envoyez ou fermez d’abord le message que vous rédigez.",
"Mail to show": "Courrier à afficher",
"My mail": "Mon courrier",
"Locked account": "Compte verrouillé",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+43 -4
View File
@@ -138,8 +138,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。", "The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。",
"Nothing to show": "表示するものはありません", "Nothing to show": "表示するものはありません",
"Could not be loaded": "読み込めませんでした", "Could not be loaded": "読み込めませんでした",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "詳細なメトリクス、配信キュー、ログ、サーバー設定は INBUXA Admin にあります。", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "詳細なメトリクス、配信キュー、ログ、サーバー設定は inbuxa Admin にあります。",
"Open INBUXA Admin": "INBUXA Admin を開く", "Open inbuxa Admin": "inbuxa Admin を開く",
"Default group role": "グループの既定ロール", "Default group role": "グループの既定ロール",
"A group needs an address.": "グループにはアドレスが必要です。", "A group needs an address.": "グループにはアドレスが必要です。",
"New group": "新しいグループ", "New group": "新しいグループ",
@@ -211,7 +211,7 @@ export const catalog: Catalog = {
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "メールサーバーはこのロールを既定で {kinds} に付与します。ここでの変更は、この方法でロールを持つ全員に反映されます。", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "メールサーバーはこのロールを既定で {kinds} に付与します。ここでの変更は、この方法でロールを持つ全員に反映されます。",
"Builds on": "継承元", "Builds on": "継承元",
"Permissions": "権限", "Permissions": "権限",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "メールサーバーがこのロールを既定で付与するため、削除できません。先に INBUXA Admin で既定値を変更してください。", "The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "メールサーバーがこのロールを既定で付与するため、削除できません。先に inbuxa Admin で既定値を変更してください。",
"This role carries permissions yours doesn't.": "このロールにはあなたのロールにない権限があります。", "This role carries permissions yours doesn't.": "このロールにはあなたのロールにない権限があります。",
"Create role": "ロールを作成", "Create role": "ロールを作成",
"builds on this one": "このロールを継承しています", "builds on this one": "このロールを継承しています",
@@ -808,6 +808,9 @@ export const catalog: Catalog = {
"Text size": "文字サイズ", "Text size": "文字サイズ",
"Font size": "フォントサイズ", "Font size": "フォントサイズ",
"Small": "小", "Small": "小",
"Original size": "元のサイズ",
"Drag to resize": "ドラッグしてサイズを変更",
"Image size": "画像のサイズ",
"Medium": "中", "Medium": "中",
"Large": "大", "Large": "大",
"Huge": "特大", "Huge": "特大",
@@ -1046,7 +1049,6 @@ export const catalog: Catalog = {
"Select a conversation to read it here · Press {key} for shortcuts": "スレッドを選ぶとここに表示されます · {key} でショートカット一覧", "Select a conversation to read it here · Press {key} for shortcuts": "スレッドを選ぶとここに表示されます · {key} でショートカット一覧",
"Select a message to read it here · Press {key} for shortcuts": "メールを選ぶとここに表示されます · {key} でショートカット一覧", "Select a message to read it here · Press {key} for shortcuts": "メールを選ぶとここに表示されます · {key} でショートカット一覧",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "ヒント: スレッド上で {key} を押すとラベルを付けられます。検索には {operator} が使えます。", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "ヒント: スレッド上で {key} を押すとラベルを付けられます。検索には {operator} が使えます。",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "{server} のための、軽快で使いやすいオープンソースのウェブメール。JMAP で動作します。",
// ── Filters, vacation, capability notices ────────────────────────── // ── Filters, vacation, capability notices ──────────────────────────
"Thanks for your message. I'm away until … and will reply when I'm back.": "メールをありがとうございます。……まで不在にしており、戻り次第ご返信いたします。", "Thanks for your message. I'm away until … and will reply when I'm back.": "メールをありがとうございます。……まで不在にしており、戻り次第ご返信いたします。",
@@ -1388,6 +1390,8 @@ export const catalog: Catalog = {
"Collapse all": "すべて折りたたむ", "Collapse all": "すべて折りたたむ",
"Expand all": "すべて展開", "Expand all": "すべて展開",
"Send now instead": "予約をやめて今すぐ送信", "Send now instead": "予約をやめて今すぐ送信",
"This message is rich text": "このメールはリッチテキストです",
"This message is plain text": "このメールはプレーンテキストです",
"Switch to plain text": "プレーンテキストに切り替え", "Switch to plain text": "プレーンテキストに切り替え",
"Switch to rich text": "リッチテキストに切り替え", "Switch to rich text": "リッチテキストに切り替え",
"{used} of {total} used": "{total} 中 {used} を使用", "{used} of {total} used": "{total} 中 {used} を使用",
@@ -1721,6 +1725,41 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "確認のため {phrase} と入力してください", "To confirm, type {phrase}": "確認のため {phrase} と入力してください",
"Turn off legacy protocols": "従来のメールプロトコルをオフにする", "Turn off legacy protocols": "従来のメールプロトコルをオフにする",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。",
// ── About: inbuxa, the suite ────────────────────────────────────
"{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} は完全なメールスイートです。メールサーバー、それを管理するコンソール、そしてこのWebメール。それぞれ独立したプログラムで、まとめてインストールでき、AGPLのもとで自由に使えます。",
"The suite": "スイート",
"Administration console": "管理コンソール",
"Webmail": "Webメール",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Webメールのノード",
"Mail server node": "メールサーバーのノード",
"{host} does not resolve": "{host} の名前解決ができません",
"unavailable": "利用できません",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "言語モデルの見解",
"One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "組織ではメールアプリ向けの {protocols} がオフになっています。これを使うメールアプリはこのアカウントに接続できませんが、ほかのアプリは接続できます。",
"Some legacy mail protocols are off for your organization: {protocols}.": "組織では一部の従来のメールプロトコルがオフになっています: {protocols}。",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "{tenant} では一部がオフです: {protocols}。管理コンソールで 1 つずつ切り替えてください。",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "削除、ロック、訴訟ホールドは管理コンソールで行います。コンソールでは理由が記録され、ホールドの対象は保持されます。",
"Open in the console": "コンソールで開く",
// inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "{name} にアクセスできなくなりました。自分のメールに戻ります。",
"You can't send from {name}. It was handed to you to read, not to send as.": "{name} から送信することはできません。このアカウントは閲覧のために委任されています。",
"This account was handed to you to read. Nothing in it can be changed.": "このアカウントは閲覧用に委任されています。内容を変更することはできません。",
"You can file and move mail in this account, but not delete it.": "このアカウントのメールは整理・移動できますが、削除はできません。",
"Read only": "閲覧のみ",
"Read and organize": "閲覧と整理",
"Full access": "フルアクセス",
"Locked account:": "ロックされたアカウント:",
"You can send as this account": "このアカウントとして送信できます",
"Back to my mail": "自分のメールに戻る",
"Send or close the message you're writing first.": "作成中のメッセージを先に送信するか閉じてください。",
"Mail to show": "表示するメール",
"My mail": "自分のメール",
"Locked account": "ロックされたアカウント",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+104 -62
View File
@@ -1,13 +1,16 @@
import type { Catalog } from "@/lib/i18n"; import type { Catalog } from "@/lib/i18n";
/** /**
* Dutch — generated by AI, and not reviewed by a native speaker. * Dutch — generated by AI, then read and corrected by a native speaker.
* *
* Same standing as German and French: written against the terminology Dutch * Michael (mbjboon82, https://git.coffeylabs.org/mbjboon82; also
* mail clients already use rather than invented, marked Beta in the picker, * mbjboon-netizen) reviewed the whole catalog, this one and permissions/nl.ts,
* and carrying a report link that is the review process until somebody who * and signed it off in September 2026, so Dutch is the first language out of
* speaks Dutch signs it off. A missing string renders its English source, so * Beta. The Dutch wording is his; where it differs from what an earlier draft
* deleting a bad entry is a valid fix. * or a style guide would choose, his call stands. The reviewer's notes below
* are kept where they were left. A missing string still renders its English
* source, and strings added after the review have not been read by a Dutch
* speaker yet.
* *
* ── Decisions this file is consistent about ────────────────────────────── * ── Decisions this file is consistent about ──────────────────────────────
* --- Native speaker note: i would keep the more formal "u" and "uw" --- * --- Native speaker note: i would keep the more formal "u" and "uw" ---
@@ -134,8 +137,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.", "The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.",
"Nothing to show": "Niets om te tonen", "Nothing to show": "Niets om te tonen",
"Could not be loaded": "Kon niet worden geladen", "Could not be loaded": "Kon niet worden geladen",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in INBUXA Admin.", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in inbuxa Admin.",
"Open INBUXA Admin": "INBUXA Admin openen", "Open inbuxa Admin": "inbuxa Admin openen",
"Default group role": "Standaardrol voor groepen", "Default group role": "Standaardrol voor groepen",
"A group needs an address.": "Een groep heeft een adres nodig.", "A group needs an address.": "Een groep heeft een adres nodig.",
"New group": "Nieuwe groep", "New group": "Nieuwe groep",
@@ -207,7 +210,7 @@ export const catalog: Catalog = {
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "De mailserver geeft deze rol standaard aan {kinds}. Een wijziging hier geldt voor iedereen die hem zo heeft.", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "De mailserver geeft deze rol standaard aan {kinds}. Een wijziging hier geldt voor iedereen die hem zo heeft.",
"Builds on": "Bouwt voort op", "Builds on": "Bouwt voort op",
"Permissions": "Rechten", "Permissions": "Rechten",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "De mailserver geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in INBUXA Admin.", "The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "De mailserver geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in inbuxa Admin.",
"This role carries permissions yours doesn't.": "Deze rol heeft rechten die uw rol niet heeft.", "This role carries permissions yours doesn't.": "Deze rol heeft rechten die uw rol niet heeft.",
"Create role": "Rol aanmaken", "Create role": "Rol aanmaken",
"builds on this one": "bouwt voort op deze", "builds on this one": "bouwt voort op deze",
@@ -441,7 +444,7 @@ export const catalog: Catalog = {
"Move up": "Omhoog", "Move up": "Omhoog",
"Move down": "Omlaag", "Move down": "Omlaag",
"Drag to reorder": "Sleep om te herschikken", "Drag to reorder": "Sleep om te herschikken",
"Right-click for options": "Rechtsklik voor opties", "Right-click for options": "Rechtermuisknop voor opties",
// ── Mail ─────────────────────────────────────────────────────────── // ── Mail ───────────────────────────────────────────────────────────
"Mail": "E-mail", "Mail": "E-mail",
@@ -628,7 +631,7 @@ export const catalog: Catalog = {
"Working hours": "Werktijden", "Working hours": "Werktijden",
"Working hours start": "Werktijd begint", "Working hours start": "Werktijd begint",
"Working hours end": "Werktijd eindigt", "Working hours end": "Werktijd eindigt",
"Color categories": "Kleurcategorieën", "Color categories": "Kleur categorieën",
"Category": "Categorie", "Category": "Categorie",
"No category": "Geen categorie", "No category": "Geen categorie",
"New category": "Nieuwe categorie", "New category": "Nieuwe categorie",
@@ -806,15 +809,18 @@ export const catalog: Catalog = {
"Text size": "Tekstgrootte", "Text size": "Tekstgrootte",
"Font size": "Lettergrootte", "Font size": "Lettergrootte",
"Small": "Klein", "Small": "Klein",
"Original size": "Oorspronkelijke grootte",
"Drag to resize": "Sleep om het formaat te wijzigen",
"Image size": "Afbeeldingsgrootte",
"Medium": "Middel", "Medium": "Middel",
"Large": "Groot", "Large": "Groot",
"Huge": "Zeer groot", "Huge": "Zeer groot",
"Sidebar": "Zijbalk", "Sidebar": "Zijbalk",
"Show labels in the sidebar": "Labels in de zijbalk tonen", "Show labels in the sidebar": "Labels in de zijbalk tonen",
"Collapse sidebar to icons": "Zijbalk inklappen tot pictogrammen", "Collapse sidebar to icons": "Zijbalk inklappen en toon alleen pictogrammen",
"Apply the theme to messages too": "Thema ook op berichten toepassen", "Apply the theme to messages too": "Thema ook op berichten toepassen",
"Apply it even to mail that styles itself": "Pas dit ook toe op e-mail met eigen vormgeving", "Apply it even to mail that styles itself": "Pas dit ook toe op e-mails met eigen vormgeving",
"Most marketing and receipt mail sets a color somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colors: backgrounds they laid the message on are dropped, while buttons and colored banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Bijna alle reclame- en bonmail zet ergens een kleur, waardoor de instelling hierboven vrijwel alles op een witte kaart laat staan. Met deze optie wordt het thema over de kleuren van de afzender heen gelegd: achtergronden waarop het bericht is geplaatst vervallen, terwijl knoppen en gekleurde banners blijven staan zodat hun tekst leesbaar blijft. Sommige berichten overleven dat niet ongeschonden, en daarom is dit een aparte instelling.", "Most marketing and receipt mail sets a color somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colors: backgrounds they laid the message on are dropped, while buttons and colored banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "De meeste marketing- en ontvangst-/bevestigingsmails stellen ergens een kleur in, waardoor de instelling hierboven vrijwel alles op een witte kaart laat staan. Met deze optie wordt het thema over de kleuren van de afzender heen gelegd: achtergronden waarop het bericht is geplaatst vervallen, terwijl knoppen en gekleurde banners blijven staan zodat hun tekst leesbaar blijft. Sommige e-mails zullen hierdoor niet helemaal intact blijven. Daarom staat deze optie apart.",
"Swiping": "Vegen", "Swiping": "Vegen",
"Swipe left": "Naar links vegen", "Swipe left": "Naar links vegen",
"Swipe right": "Naar rechts vegen", "Swipe right": "Naar rechts vegen",
@@ -822,7 +828,7 @@ export const catalog: Catalog = {
"Export settings": "Instellingen exporteren", "Export settings": "Instellingen exporteren",
"Import settings": "Instellingen importeren", "Import settings": "Instellingen importeren",
"Settings imported": "Instellingen geïmporteerd", "Settings imported": "Instellingen geïmporteerd",
"Invalid settings file": "Ongeldig instellingenbestand", "Invalid settings file": "Instellingenbestand is ongeldig",
"Reset to defaults": "Standaardwaarden herstellen", "Reset to defaults": "Standaardwaarden herstellen",
"Default mail app": "Standaard e-mailprogramma", "Default mail app": "Standaard e-mailprogramma",
"Documentation": "Documentatie", "Documentation": "Documentatie",
@@ -835,7 +841,7 @@ export const catalog: Catalog = {
"Account": "Account", "Account": "Account",
"Max upload": "Maximale upload", "Max upload": "Maximale upload",
"{size} MB": "{size} MB", "{size} MB": "{size} MB",
"KB": "kB", "KB": "KB",
"Image privacy proxy": "Privacyproxy voor afbeeldingen", "Image privacy proxy": "Privacyproxy voor afbeeldingen",
"enabled": "ingeschakeld", "enabled": "ingeschakeld",
"disabled": "uitgeschakeld", "disabled": "uitgeschakeld",
@@ -844,7 +850,7 @@ export const catalog: Catalog = {
"hidden": "verborgen", "hidden": "verborgen",
"connected": "verbonden", "connected": "verbonden",
"reconnecting…": "opnieuw verbinden…", "reconnecting…": "opnieuw verbinden…",
"AGPL-3.0 source": "AGPL-3.0-broncode", "AGPL-3.0 source": "AGPL-3.0 broncode",
// ── Identities, templates, filters ───────────────────────────────── // ── Identities, templates, filters ─────────────────────────────────
"Identities & signatures": "Identiteiten en handtekeningen", "Identities & signatures": "Identiteiten en handtekeningen",
@@ -868,9 +874,9 @@ export const catalog: Catalog = {
"Insert template": "Sjabloon invoegen", "Insert template": "Sjabloon invoegen",
"Template text…": "Sjabloontekst…", "Template text…": "Sjabloontekst…",
"Subject: {subject}": "Onderwerp: {subject}", "Subject: {subject}": "Onderwerp: {subject}",
"Filters & rules": "Filters en regels", "Filters & rules": "Filters en filterregels",
"Filters unavailable": "Filters niet beschikbaar", "Filters unavailable": "Filters niet beschikbaar",
"Rules": "Regels", "Rules": "Filterregels",
"Rule name": "Naam van de regel", "Rule name": "Naam van de regel",
"New rule": "Nieuwe regel", "New rule": "Nieuwe regel",
"Delete rule": "Regel verwijderen", "Delete rule": "Regel verwijderen",
@@ -893,7 +899,7 @@ export const catalog: Catalog = {
"does not exist": "bestaat niet", "does not exist": "bestaat niet",
"is larger than": "is groter dan", "is larger than": "is groter dan",
"is smaller than": "is kleiner dan", "is smaller than": "is kleiner dan",
"Stop processing more rules": "Stoppen met verdere regels", "Stop processing more rules": "Geen verdere filterregels verwerken",
"keep copy": "kopie bewaren", "keep copy": "kopie bewaren",
"Forward to": "Doorsturen naar", "Forward to": "Doorsturen naar",
"Reject with message": "Weigeren met bericht", "Reject with message": "Weigeren met bericht",
@@ -904,8 +910,8 @@ export const catalog: Catalog = {
"Delete script": "Script verwijderen", "Delete script": "Script verwijderen",
"Sieve source": "Sieve-broncode", "Sieve source": "Sieve-broncode",
"Preview generated Sieve script": "Gegenereerd Sieve-script bekijken", "Preview generated Sieve script": "Gegenereerd Sieve-script bekijken",
"Start with rules": "Beginnen met regels", "Start with rules": "Beginnen met filterregels",
"Switch to rules?": "Overschakelen naar regels?", "Switch to rules?": "Overschakelen naar filterregels?",
"Create filter": "Filter maken", "Create filter": "Filter maken",
"Filter messages like this": "Berichten zoals dit filteren", "Filter messages like this": "Berichten zoals dit filteren",
"Filter messages like this…": "Berichten zoals dit filteren…", "Filter messages like this…": "Berichten zoals dit filteren…",
@@ -926,7 +932,7 @@ export const catalog: Catalog = {
"Code from your authenticator": "Code uit uw authenticator-app", "Code from your authenticator": "Code uit uw authenticator-app",
"Two-factor authentication": "Tweefactorauthenticatie", "Two-factor authentication": "Tweefactorauthenticatie",
"Turn off two-factor authentication": "Tweefactorauthenticatie uitschakelen", "Turn off two-factor authentication": "Tweefactorauthenticatie uitschakelen",
"Your password alone will be enough to sign in again.": "Uw wachtwoord alleen volstaat dan weer om in te loggen.", "Your password alone will be enough to sign in again.": "Met alleen uw wachtwoord kunt u opnieuw inloggen.",
"App passwords": "App-wachtwoorden", "App passwords": "App-wachtwoorden",
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Uw organisatie staat alleen {app} en JMAP-apps toe, dus mail-apps op telefoon en computer kunnen geen verbinding maken met dit account.", "Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Uw organisatie staat alleen {app} en JMAP-apps toe, dus mail-apps op telefoon en computer kunnen geen verbinding maken met dit account.",
"New app password for": "Nieuw app-wachtwoord voor", "New app password for": "Nieuw app-wachtwoord voor",
@@ -951,19 +957,19 @@ export const catalog: Catalog = {
"Type": "Type", "Type": "Type",
"Email or username": "E-mail of gebruikersnaam", "Email or username": "E-mail of gebruikersnaam",
"Use your usual address as the username.": "Gebruik uw gebruikelijke adres als gebruikersnaam.", "Use your usual address as the username.": "Gebruik uw gebruikelijke adres als gebruikersnaam.",
"Fast, friendly webmail. Your mailbox, your way.": "Snelle, prettige webmail. Uw postbus, op uw manier.", "Fast, friendly webmail. Your mailbox, your way.": "Snelle, prettige webmail. Uw postvak, op uw manier.",
"Notifications": "Meldingen", "Notifications": "Meldingen",
"Notifications are blocked in your browser settings.": "Meldingen zijn geblokkeerd in uw browserinstellingen.", "Notifications are blocked in your browser settings.": "Meldingen zijn geblokkeerd in uw browserinstellingen.",
"Not supported in this browser.": "Niet ondersteund in deze browser.", "Not supported in this browser.": "Niet ondersteund in deze browser.",
"Desktop notifications while {app} is open": "Systeemmeldingen terwijl {app} open is", "Desktop notifications while {app} is open": "Systeemmeldingen terwijl {app} open is",
"Notify me even when {app} is closed": "Ook melden wanneer {app} gesloten is", "Notify me even when {app} is closed": "Ook melden wanneer {app} gesloten is",
"Play a sound for new mail": "Geluid afspelen bij nieuwe post", "Play a sound for new mail": "Geluid afspelen bij nieuwe e-mail",
"Test notification": "Testmelding", "Test notification": "Testmelding",
"Background notifications are on": "Achtergrondmeldingen staan aan", "Background notifications are on": "Achtergrondmeldingen staan aan",
"The tab title and favicon always show your unread Inbox count.": "De tabbladtitel en het favicon tonen altijd het aantal ongelezen berichten in Postvak IN.", "The tab title and favicon always show your unread Inbox count.": "De titel van het tabblad en het favicon tonen altijd het aantal ongelezen berichten in Postvak IN.",
"Live updates are delivered via JMAP push ({state}).": "Live-updates komen binnen via JMAP-push ({state}).", "Live updates are delivered via JMAP push ({state}).": "Live-updates komen binnen via JMAP-push ({state}).",
"Shows a system notification when new mail arrives in your Inbox while the tab is in the background.": "Toont een systeemmelding wanneer er nieuwe post in Postvak IN aankomt terwijl het tabblad op de achtergrond staat.", "Shows a system notification when new mail arrives in your Inbox while the tab is in the background.": "Toont een systeemmelding wanneer er een nieuwe e-mail in Postvak IN aankomt terwijl het tabblad op de achtergrond staat.",
// ── Editor, search, shortcuts, misc ──────────────────────────────── // ── Editor, search, shortcuts, misc ────────────────────────────────
"Formatting": "Opmaak", "Formatting": "Opmaak",
@@ -1027,20 +1033,20 @@ export const catalog: Catalog = {
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Afbeeldingen worden opgeslagen in uw bestanden (map “ihasmail”) en bij verzending ingesloten.", "Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Afbeeldingen worden opgeslagen in uw bestanden (map “ihasmail”) en bij verzending ingesloten.",
"Thanks for your message. I'm away until … and will reply when I'm back.": "Bedankt voor uw bericht. Ik ben afwezig tot … en reageer zodra ik terug ben.", "Thanks for your message. I'm away until … and will reply when I'm back.": "Bedankt voor uw bericht. Ik ben afwezig tot … en reageer zodra ik terug ben.",
"Automatically reply to people who email you while you're away. Each sender gets at most one reply.": "Automatisch antwoorden aan mensen die u mailen terwijl u weg bent. Elke afzender krijgt hoogstens één antwoord.", "Automatically reply to people who email you while you're away. Each sender gets at most one reply.": "Automatisch antwoorden aan mensen die u mailen terwijl u weg bent. Elke afzender krijgt hoogstens één antwoord.",
"Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.": "Inkomende post automatisch sorteren. De regels draaien op de server (Sieve) en gelden dus voor elke client die u gebruikt.", "Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.": "Inkomende e-mails automatisch sorteren. De filterregels staan op de server (Sieve) en gelden dus voor elke client die u gebruikt.",
"Canned responses you can insert into any message from the composer's template button.": "Kant-en-klare antwoorden die u via de sjabloonknop in elk bericht kunt invoegen.", "Canned responses you can insert into any message from the composer's template button.": "Kant-en-klare antwoorden die u via de sjabloonknop in elk bericht kunt invoegen.",
"Create a rule to move newsletters to a folder, flag important senders, or forward mail.": "Maak een regel om nieuwsbrieven naar een map te verplaatsen, belangrijke afzenders te markeren of post door te sturen.", "Create a rule to move newsletters to a folder, flag important senders, or forward mail.": "Maak een filterregel om nieuwsbrieven naar een map te verplaatsen, belangrijke afzenders te markeren of een e-mail door te sturen.",
"Advanced: manage raw Sieve scripts. Only one script can be active at a time.": "Geavanceerd: Sieve-scripts rechtstreeks beheren. Er kan maar één script tegelijk actief zijn.", "Advanced: manage raw Sieve scripts. Only one script can be active at a time.": "Geavanceerd: Sieve-scripts rechtstreeks beheren. Er kan maar één script tegelijk actief zijn.",
"Only part of your filter script arrived.": "Slechts een deel van uw filterscript is aangekomen.", "Only part of your filter script arrived.": "Slechts een deel van uw filterscript is aangekomen.",
"Your active script “{name}” was written by hand.": "Uw actieve script “{name}” is met de hand geschreven.", "Your active script “{name}” was written by hand.": "Uw actieve script “{name}” is met de hand geschreven.",
"Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.": "Een ander script (“{name}”) is actief. Als u hier regels opslaat, wordt in plaats daarvan het script “ihasmail” geactiveerd.", "Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.": "Een ander script (“{name}”) is actief. Als u hier filterregels opslaat, wordt in plaats daarvan het script “ihasmail” geactiveerd.",
"“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.": "“{name}” wordt gedeactiveerd (niet verwijderd) en een nieuw script “ihasmail” neemt het over.", "“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.": "“{name}” wordt gedeactiveerd (niet verwijderd) en een nieuw script “ihasmail” neemt het over.",
"Sieve filtering is not available for this account.": "Sieve-filtering is niet beschikbaar voor dit account.", "Sieve filtering is not available for this account.": "Sieve-filtering is niet beschikbaar voor dit account.",
"Sieve filtering is not enabled for this account.": "Sieve-filtering is niet ingeschakeld voor dit account.", "Sieve filtering is not enabled for this account.": "Sieve-filtering is niet ingeschakeld voor dit account.",
"Vacation responses are not available for this account.": "Afwezigheidsantwoorden zijn niet beschikbaar voor dit account.", "Vacation responses are not available for this account.": "Afwezigheidsantwoorden zijn niet beschikbaar voor dit account.",
"This account does not have the JMAP calendars capability.": "Dit account beschikt niet over de JMAP-agendafunctie.", "This account does not have the JMAP calendars capability.": "Dit account beschikt niet over de JMAP-agenda functie.",
"This account does not have the JMAP contacts capability.": "Dit account beschikt niet over de JMAP-contactenfunctie.", "This account does not have the JMAP contacts capability.": "Dit account beschikt niet over de JMAP-contacten functie.",
"This account does not have the JMAP file storage capability.": "Dit account beschikt niet over de JMAP-bestandsopslagfunctie.", "This account does not have the JMAP file storage capability.": "Dit account beschikt niet over de JMAP-bestandsopslag functie.",
// ── Labels held in constants, translated where they render ───────── // ── Labels held in constants, translated where they render ─────────
"Add": "Toevoegen", "Add": "Toevoegen",
@@ -1099,63 +1105,62 @@ export const catalog: Catalog = {
"share sheet\u0004Share…": "Delen…", "share sheet\u0004Share…": "Delen…",
"folder": "map", "folder": "map",
"“{name}” moved into “{parent}”": "“{name}” is verplaatst naar “{parent}”", "“{name}” moved into “{parent}”": "“{name}” is verplaatst naar “{parent}”",
"“{name}” moved to the top level": "“{name}” is naar het hoogste niveau verplaatst", "“{name}” moved to the top level": "“{name}” is naar het hoofdniveau verplaatst",
"Move “{name}” to…": "“{name}” verplaatsen naar…", "Move “{name}” to…": "“{name}” verplaatsen naar…",
"Top level": "Hoogste niveau", "Top level": "Hoofdniveau",
"Could not move “{name}”: {reason}": "Kon “{name}” niet verplaatsen: {reason}", "Could not move “{name}”: {reason}": "Kon “{name}” niet verplaatsen: {reason}",
"Delete “{name}”?": "“{name}” verwijderen?", "Delete “{name}”?": "“{name}” verwijderen?",
"Rename folder": "Map hernoemen", "Rename folder": "Map hernoemen",
"Search: {query}": "Zoeken: {query}", "Search: {query}": "Zoeken: {query}",
"No conversation selected": "Geen gesprek geselecteerd", "No conversation selected": "Geen gesprek geselecteerd",
"No message selected": "Geen bericht geselecteerd", "No message selected": "Geen bericht geselecteerd",
"Drop here for the top level": "Hier neerzetten voor het hoogste niveau", "Drop here for the top level": "Hier neerzetten voor het hoofdniveau",
// ── Longer prose ─────────────────────────────────────────────────── // ── Longer prose ───────────────────────────────────────────────────
"Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)": "In e-mail zoeken (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)", "Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)": "In e-mail zoeken (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)",
"Settings → Filters & rules": "Instellingen → Filters en regels", "Settings → Filters & rules": "Instellingen → Filters en filterregels",
"Open the Mail view to see all shortcuts.": "Open de E-mailweergave om alle sneltoetsen te zien.", "Open the Mail view to see all shortcuts.": "Open de e-mailweergave om alle sneltoetsen te zien.",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Sneltoetsen in Gmail-stijl staan altijd aan. Druk overal op {key} om deze lijst te zien.", "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Sneltoetsen in Gmail-stijl staan altijd aan. Druk overal op {key} om deze lijst te zien.",
"Select a conversation to read it here · Press {key} for shortcuts": "Selecteer een gesprek om het hier te lezen · {key} voor sneltoetsen", "Select a conversation to read it here · Press {key} for shortcuts": "Selecteer een gesprek om het hier te lezen · {key} voor sneltoetsen",
"Select a message to read it here · Press {key} for shortcuts": "Selecteer een bericht om het hier te lezen · {key} voor sneltoetsen", "Select a message to read it here · Press {key} for shortcuts": "Selecteer een bericht om het hier te lezen · {key} voor sneltoetsen",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tip: druk op {key} bij een gesprek om labels toe te wijzen. Zoek met {operator}.", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tip: druk op {key} bij een gesprek om labels toe te wijzen. Zoek met {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Een snelle, prettige, opensource webmail voor {server}, gebouwd op JMAP.",
"Defaults for the calendar views and new events.": "Standaardwaarden voor de agendaweergaven en nieuwe afspraken.", "Defaults for the calendar views and new events.": "Standaardwaarden voor de agendaweergaven en nieuwe afspraken.",
"Replies will go to this address instead of the From address": "Antwoorden gaan naar dit adres in plaats van naar het afzenderadres", "Replies will go to this address instead of the From address": "Antwoorden gaan naar dit adres in plaats van naar het afzenderadres",
"Replies to mail sent from this identity go here instead of the From address.": "Antwoorden op post die vanaf deze identiteit is verzonden, komen hier aan in plaats van bij het afzenderadres.", "Replies to mail sent from this identity go here instead of the From address.": "Antwoorden op e-mails die vanaf deze identiteit is verzonden, komen hier aan in plaats van bij het afzenderadres.",
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Een nieuwe identiteit moet een adres gebruiken waarvandaan dit account mag verzenden (aliassen die op de server zijn ingesteld).", "New identities must use an address this account is allowed to send from (aliases configured on the server).": "Nieuwe identiteiten moeten een adres gebruiken waar dit account vanaf mag verzenden (aliassen die op de server zijn ingesteld).",
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Wordt niet aangeboden bij het opstellen. Het adres ontvangt nog steeds post, en u kunt er weer vanaf verzenden door het opnieuw te tonen.", "Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Wordt niet aangeboden bij het opstellen. Het adres ontvangt nog steeds e-mails, en u kunt er weer vanaf verzenden door het opnieuw te tonen.",
"Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.": "Elke identiteit is een afzenderadres met een eigen naam, antwoordadres en handtekening. De standaardidentiteit is voorgeselecteerd bij het opstellen; stel een antwoordadres in wanneer antwoorden ergens anders heen moeten dan naar het afzenderadres.", "Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.": "Elke identiteit is een afzenderadres met een eigen naam, antwoordadres en handtekening. De standaardidentiteit is voorgeselecteerd bij het opstellen; stel een antwoordadres in wanneer antwoorden ergens anders heen moeten dan naar het afzenderadres.",
"This signature is larger than the server's {limit}-byte limit. {app} will keep the full version in your Files and store a short text fallback on the server \u2014 other mail clients will see the plain-text version.": "Deze handtekening is groter dan de limiet van {limit} bytes van de server. {app} bewaart de volledige versie in uw Bestanden en zet een korte tekstversie op de server \u2014 andere e-mailprogramma's zien de platte-tekstversie.", "This signature is larger than the server's {limit}-byte limit. {app} will keep the full version in your Files and store a short text fallback on the server \u2014 other mail clients will see the plain-text version.": "Deze handtekening is groter dan de limiet van {limit} bytes van de server. {app} bewaart de volledige versie in uw bestanden en zet een korte tekstversie op de server \u2014 andere e-mailprogramma's zien de platte-tekstversie.",
"Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.": "Categorieën in Outlook-stijl die u via het rechtsklikmenu of de afsprakeneditor aan afspraken kunt toewijzen. De categorienaam wordt in de afspraak opgeslagen en synchroniseert dus met andere clients.", "Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.": "Categorieën in Outlook-stijl die u via het rechtermuisknop-menu of de afsprakeneditor aan afspraken kunt toewijzen. De categorienaam wordt in de afspraak opgeslagen en synchroniseert met andere clients.",
"Plain-text mail already follows the theme. With this on, HTML mail that brings no colors of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.": "Platte-tekstberichten volgen het thema al. Met deze optie doen HTML-berichten zonder eigen kleuren dat ook, in plaats van op een witte achtergrond te staan. Berichten met een eigen vormgeving blijven precies zoals de afzender ze heeft ontworpen.", "Plain-text mail already follows the theme. With this on, HTML mail that brings no colors of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.": "Platte-tekstberichten volgen het thema al. Met deze optie doen HTML-berichten zonder eigen kleuren dat ook, in plaats van op een witte achtergrond te staan. Berichten met een eigen vormgeving blijven precies zoals de afzender ze heeft ontworpen.",
"This is separate from {setting} in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.": "Dit staat los van {setting} onder Algemeen, waar wordt bepaald hoe datums, tijden en getallen worden geschreven. U kunt een Engelse interface met Nederlandse datums lezen, of andersom.", "This is separate from {setting} in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.": "Dit staat los van {setting} onder Algemeen, waar wordt bepaald hoe datums, tijden en getallen worden geschreven. U kunt bijvoorbeeld een Engelse interface met Nederlandse datums kiezen, of andersom.",
"On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.": "Sleep op een aanraakscherm een bericht opzij om er iets mee te doen. Elke richting kan één ding doen, of niets. Deze instelling volgt uw account, zodat telefoon en tablet overeenkomen; met een muis wordt ze genegeerd en blijft slepen naar mappen werken.", "On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.": "Veeg op een touchscreen een bericht opzij om er een actie op uit te voeren. Elke richting kan een eigen actie uitvoeren, of helemaal niets doen. Deze instellingen worden aan uw account gekoppeld, zodat uw telefoon en tablet dezelfde instellingen gebruiken. Met een muis worden deze instellingen genegeerd en kunt u berichten gewoon naar mappen slepen.",
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Dit scherm heeft geen aanraakscherm, dus hier verandert niets. Uw telefoon of tablet neemt deze instellingen over.", "This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Dit scherm heeft geen aanraakscherm, dus hier verandert niets. Uw telefoon of tablet neemt deze instellingen over.",
"Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.": "Een bericht ingedrukt houden selecteert het, een map ingedrukt houden opent het menu. Trek de bovenkant van de berichtenlijst omlaag om nieuwe post op te halen.", "Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.": "Houd een bericht ingedrukt om het te selecteren en houd een map ingedrukt om het menu ervan te openen. Trek de bovenkant van de berichtenlijst omlaag om nieuwe e-mails op te halen.",
"A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.": "Een bevestiging vertelt de aanvrager dat dit adres actief is en wanneer het bericht is gelezen, en de afzender bepaalt waar die heen gaat — daarom is er geen automatische optie. Bij bulkpost, mailinglijsten en alles wat als automatisch verzonden is gemarkeerd, wordt er nooit een aangeboden.", "A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.": "Een bevestiging vertelt de aanvrager dat dit adres actief is en wanneer het bericht is gelezen, en de afzender bepaalt waar die heen gaat — daarom is er geen automatische optie. Bulkmail, mailinglijsten en alles wat als automatisch verzonden is gemarkeerd, krijgen nooit deze optie aangeboden.",
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API \u2014 you can still make {app} the default from your operating system if you install it as an app.": "Deze browser kan geen programma's registreren voor {scheme}-links. Safari heeft daar in het bijzonder geen voorziening voor \u2014 u kunt {app} nog steeds als standaard instellen via uw besturingssysteem als u het als app installeert.", "This browser cannot register apps for {scheme} links. Safari, in particular, has no such API \u2014 you can still make {app} the default from your operating system if you install it as an app.": "Deze browser kan geen programma's registreren voor {scheme}-links. Safari in het bijzonder heeft daar geen voorziening voor \u2014 u kunt {app} nog steeds als standaard instellen via uw besturingssysteem als u het als een app installeert.",
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Registreren voor {scheme}-links vereist een beveiligde (HTTPS-)verbinding.", "Registering for {scheme} links requires a secure (HTTPS) connection.": "Registreren voor {scheme}-links vereist een beveiligde (HTTPS-)verbinding.",
"Open {scheme} links \u2014 in web pages, documents and other apps \u2014 in {app} instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings \u203a Privacy and security \u203a Site settings \u203a Protocol handlers; Firefox: Settings \u203a General \u203a Applications).": "{scheme}-links \u2014 op webpagina's, in documenten en in andere programma's \u2014 openen in {app} in plaats van in een lokaal e-mailprogramma. Uw browser vraagt om bevestiging, en u kunt dit later wijzigen in zijn eigen instellingen (Chrome: Instellingen \u203a Privacy en beveiliging \u203a Site-instellingen \u203a Protocol-handlers; Firefox: Instellingen \u203a Algemeen \u203a Programma's).", "Open {scheme} links \u2014 in web pages, documents and other apps \u2014 in {app} instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings \u203a Privacy and security \u203a Site settings \u203a Protocol handlers; Firefox: Settings \u203a General \u203a Applications).": "{scheme}-links \u2014 op webpagina's, in documenten en in andere programma's \u2014 openen in {app} in plaats van in een lokaal e-mailprogramma. Uw browser vraagt om bevestiging, en u kunt dit later wijzigen in zijn eigen instellingen (Chrome: Instellingen \u203a Privacy en beveiliging \u203a Site-instellingen \u203a Protocol-handlers; Firefox: Instellingen \u203a Algemeen \u203a Programma's).",
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Aangevraagd in deze browser. Of het effect heeft gehad, bepaalt de browser — controleer zijn instellingen als e-mail links nog elders openen.", "Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Aangevraagd in deze browser. Of het effect heeft gehad, bepaalt de browser — controleer zijn instellingen als e-mail links nog elders openen.",
"For a system-wide default, install {app} as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer {app} directly wherever it asks which mail app to use.": "Installeer {app} eerst als app voor een systeembrede standaard (in Chrome: het installatiepictogram in de adresbalk). Uw besturingssysteem kan {app} dan overal direct aanbieden waar het vraagt welk e-mailprogramma gebruikt moet worden.", "For a system-wide default, install {app} as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer {app} directly wherever it asks which mail app to use.": "Installeer {app} eerst als app voor een systeembrede standaard (in Chrome: het installatiepictogram in de adresbalk). Uw besturingssysteem kan {app} dan overal direct aanbieden waar het vraagt welk e-mailprogramma gebruikt moet worden.",
"Needs a browser with the Push API and a mail server that publishes a push key.": "Vereist een browser met de Push-API en een mailserver die een push-sleutel publiceert.", "Needs a browser with the Push API and a mail server that publishes a push key.": "Vereist een browser met de Push-API en een mailserver die een push-sleutel publiceert.",
"Your mail server delivers these straight to your browser, so they arrive with no {app} tab open, naming the sender and subject. Your browser still has to be running \u2014 if you quit it completely, notifications wait and arrive when you open it again.": "Uw mailserver levert deze rechtstreeks bij uw browser af, dus ze komen aan zonder geopend {app}-tabblad, met afzender en onderwerp erbij. Uw browser moet wel draaien \u2014 sluit u hem helemaal af, dan wachten de meldingen en komen ze binnen zodra u hem weer opent.", "Your mail server delivers these straight to your browser, so they arrive with no {app} tab open, naming the sender and subject. Your browser still has to be running \u2014 if you quit it completely, notifications wait and arrive when you open it again.": "Uw mailserver levert deze rechtstreeks bij uw browser af, dus ze komen aan zonder geopend {app}-tabblad, met afzender en onderwerp erbij. Uw browser moet wel open staan \u2014 sluit u hem helemaal af, dan wachten de meldingen en komen ze binnen zodra u hem weer opent.",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Uw mailserver kan deze browser wekken, maar vermeldt geen afzender of onderwerp. Uw browser moet wel draaien.", "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Uw mailserver kan deze browser activeren, maar vermeldt daarbij geen afzender of onderwerp. Uw browser moet wel open staan.",
"This is what a new-mail notification looks like.": "Zo ziet een melding van nieuwe post eruit.", "This is what a new-mail notification looks like.": "Zo ziet een melding van nieuwe e-mails eruit.",
"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.": "U bent ingelogd als {user}. Uw wachtwoord wordt nooit in de browser opgeslagen; de server bewaart het versleuteld per sessie om met de mailserver te communiceren.", "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.": "U bent ingelogd als {user}. Uw wachtwoord wordt nooit in de browser opgeslagen; de server bewaart het versleuteld per sessie om met de mailserver te communiceren.",
"App passwords are managed by your mail administrator.": "App-wachtwoorden worden beheerd door uw mailbeheerder.", "App passwords are managed by your mail administrator.": "App-wachtwoorden worden beheerd door uw mailbeheerder.",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Als u uw wachtwoord wijzigt, worden uw andere webmailsessies uitgelogd. App-wachtwoorden blijven werken.", "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Als u uw wachtwoord wijzigt, worden uw andere webmailsessies afgemeld. App-wachtwoorden blijven werken.",
"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 \u2014 or you can turn two-factor authentication off here.": "Voor dit account staat tweefactorauthenticatie aan. {app} kan u nog niet met een code inloggen, dus inloggen op een ander apparaat vereist een app-wachtwoord \u2014 of u schakelt tweefactorauthenticatie hier uit.", "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 \u2014 or you can turn two-factor authentication off here.": "Voor dit account staat tweefactorauthenticatie aan. {app} kan u nog niet met een code inloggen, dus inloggen op een ander apparaat vereist een app-wachtwoord \u2014 of u schakelt tweefactorauthenticatie hier uit.",
"A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.": "Een apart wachtwoord voor een e-mailprogramma of apparaat, dat u afzonderlijk kunt intrekken. App-wachtwoorden slaan tweefactorcodes over en blijven dus werken in programma's die er geen kunnen vragen.", "A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.": "Een apart wachtwoord voor een e-mail app of apparaat, dat u afzonderlijk kunt intrekken. App-wachtwoorden omzeilen tweestapsverificatie, zodat ze blijven werken in apps die hier niet om kunnen vragen.",
"Copy it into {name} now — it isn't shown again.": "Neem het nu over in {name} — het wordt niet opnieuw getoond.", "Copy it into {name} now — it isn't shown again.": "Neem het nu over in {name} — het wordt niet opnieuw getoond.",
"No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.": "Geen andere gebruikers gevonden in de directory, dus er kan niemand nieuws worden toegevoegd. Bestaande gedeelde items staan hieronder en kunnen nog worden verwijderd.", "No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.": "Er zijn geen andere gebruikers in de directory gevonden, dus er kunnen geen nieuwe gebruikers worden toegevoegd. Hieronder ziet u de bestaande gedeelde items. U kunt deze nog steeds verwijderen.",
"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 geeft zijn versienummer niet door aan e-mailprogramma's, dus {app} noemt de editie als de server die opgeeft. {app} vereist 0.16 of nieuwer; inloggen weigert alles wat ouder is.", "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 geeft zijn versienummer niet door aan e-mailprogramma's, dus {app} noemt de editie als de server die opgeeft. {app} vereist 0.16 of nieuwer; inloggen weigert alle oudere versies.",
"It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.": "Het {damage}, dus de regels erin kunnen niet worden getoond of bewerkt — wat wél is aangekomen opslaan zou de rest overschrijven. Laad de pagina opnieuw om het nog eens te proberen. Uw regels staan nog op de server; hier is er niets aan veranderd.", "It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.": "Het {damage}, waardoor de filterregels hierin niet kunnen worden weergegeven of bewerkt. Als u opslaat wat wel is binnengekomen, wordt de rest overschreven. Laad de pagina opnieuw om het nogmaals te proberen. Uw filterregels staan nog steeds op de server; hier is niets aan gewijzigd.",
"The visual rule editor only manages scripts it created. You can edit the script in the {tab} tab, or start fresh with rules (the existing script will be kept but deactivated).": "De visuele regeleditor beheert alleen scripts die hij zelf heeft gemaakt. U kunt het script bewerken op het tabblad {tab}, of opnieuw beginnen met regels (het bestaande script blijft bewaard maar wordt gedeactiveerd).", "The visual rule editor only manages scripts it created. You can edit the script in the {tab} tab, or start fresh with rules (the existing script will be kept but deactivated).": "De visuele filterregel-editor beheert alleen scripts die hij zelf heeft gemaakt. U kunt het script bewerken op het tabblad {tab}, of opnieuw beginnen met filterregels (het bestaande script blijft bewaard maar wordt gedeactiveerd).",
"Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.": "Uw filterscript {damage}, dus slechts een deel is aangekomen. Een regel toevoegen zou dat deel over het geheel heen schrijven. Laad de pagina opnieuw en probeer het nog eens.", "Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.": "Uw filterregel-script {damage}, dus slechts een deel is aangekomen. Als u een filterregel toevoegt, wordt dat deel over het volledige script heen geschreven. Laad de pagina opnieuw en probeer het nogmaals.",
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Uw filterscript kon zojuist niet worden gelezen; een regel toevoegen zou het kunnen overschrijven. Laad de pagina opnieuw en probeer het nog eens.", "Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Uw filterregel-script kon niet worden gelezen; een regel toevoegen zou het kunnen overschrijven. Laad de pagina opnieuw en probeer het nog eens.",
"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.": "Uw actieve Sieve-script is met de hand geschreven, dus regels kunnen niet automatisch worden toegevoegd. Open {where} om het script te bewerken of over te stappen op beheerde regels.", "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.": "Uw actieve Sieve-script is met de hand geschreven, dus filterregels kunnen niet automatisch worden toegevoegd. Open {where} om het script te bewerken of over te stappen op beheerde filterregels.",
"Only languages {app} has been translated into appear here, so this list grows as translations land rather than ahead of them \u2014 a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Hier verschijnen alleen talen waarin {app} is vertaald; de lijst groeit dus mee met de vertalingen en niet erop vooruit \u2014 een taal die wordt aangeboden zonder teksten erachter zou de pagina laten beweren dat ze in een taal is die ze niet is.", "Only languages {app} has been translated into appear here, so this list grows as translations land rather than ahead of them \u2014 a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Hier verschijnen alleen de talen waarin {app} is vertaald; de lijst wordt uitgebreid zodra vertalingen beschikbaar komen, in plaats van vooraf \u2014 een aangeboden taal zonder bijbehorende vertalingen zou ervoor zorgen dat de pagina beweert in een taal te zijn die niet beschikbaar is.",
"tell us about it": "laat het ons weten", "tell us about it": "laat het ons weten",
"This translation was generated by AI and has not been checked by a native speaker, so it is marked Beta until somebody who speaks it signs it off. Anything that reads wrongly is worth reporting — {report}.": "Deze vertaling is door AI gemaakt en niet gecontroleerd door iemand met Nederlands als moedertaal; ze is daarom als Beta gemarkeerd tot iemand haar goedkeurt. Alles wat verkeerd klinkt, is een melding waard — {report}.", "This translation was generated by AI and has not been checked by a native speaker, so it is marked Beta until somebody who speaks it signs it off. Anything that reads wrongly is worth reporting — {report}.": "Deze vertaling is door AI gemaakt en niet gecontroleerd door iemand met Nederlands als moedertaal; ze is daarom als Beta gemarkeerd tot iemand haar goedkeurt. Alles wat verkeerd klinkt, is een melding waard — {report}.",
"{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 \u2014 {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "De eigen versie van {app} is de datum van de commit waaruit het is gebouwd, gevolgd door waar die commit vandaan kwam: {example} is gebouwd uit een commit van 30 augustus 2026 die via pull request 129 binnenkwam. Een commit die niet via zo'n verzoek kwam, draagt in plaats daarvan zijn korte SHA \u2014 {sha}. De versie zegt bewust niets over Stalwart; wat deze build van de server nodig heeft, staat op de regel hierboven.", "{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 \u2014 {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "De eigen versie van {app} is de datum van de commit waaruit het is gebouwd, gevolgd door waar die commit vandaan kwam: {example} is gebouwd uit een commit van 30 augustus 2026 die via pull request 129 binnenkwam. Een commit die niet via zo'n verzoek kwam, draagt in plaats daarvan zijn korte SHA \u2014 {sha}. De versie zegt bewust niets over Stalwart; wat deze build van de server nodig heeft, staat op de regel hierboven.",
@@ -1377,6 +1382,8 @@ export const catalog: Catalog = {
"Collapse all": "Alles samenvouwen", "Collapse all": "Alles samenvouwen",
"Expand all": "Alles uitvouwen", "Expand all": "Alles uitvouwen",
"Send now instead": "Toch nu verzenden", "Send now instead": "Toch nu verzenden",
"This message is rich text": "Dit bericht is opgemaakte tekst",
"This message is plain text": "Dit bericht is platte tekst",
"Switch to plain text": "Overschakelen naar platte tekst", "Switch to plain text": "Overschakelen naar platte tekst",
"Switch to rich text": "Overschakelen naar opgemaakte tekst", "Switch to rich text": "Overschakelen naar opgemaakte tekst",
"{used} of {total} used": "{used} van {total} gebruikt", "{used} of {total} used": "{used} van {total} gebruikt",
@@ -1710,6 +1717,41 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Typ ter bevestiging {phrase}", "To confirm, type {phrase}": "Typ ter bevestiging {phrase}",
"Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen", "Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie. Alleen {app} en JMAP-apps kunnen inloggen.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie. Alleen {app} en JMAP-apps kunnen inloggen.",
// ── About: inbuxa, the suite ────────────────────────────────────
"{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} is een complete mailsuite: een mailserver, de console waarmee je hem beheert en deze webmail, elk een eigen programma, samen geïnstalleerd en vrij onder de AGPL.",
"The suite": "De suite",
"Administration console": "Beheerconsole",
"Webmail": "Webmail",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Webmailnode",
"Mail server node": "Mailservernode",
"{host} does not resolve": "{host} wordt niet omgezet",
"unavailable": "niet beschikbaar",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Oordeel van het taalmodel",
"One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Uw organisatie heeft {protocols} uitgeschakeld voor mail-apps. Mail-apps die dat gebruiken, kunnen geen verbinding maken met dit account; andere nog wel.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Sommige verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Sommige zijn uit voor {tenant}: {protocols}. In de beheerconsole zijn ze één voor één aan en uit te zetten.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Verwijderen, vergrendelen en juridische bewaarplichten gebeuren in de beheerconsole, die de reden vastlegt en bewaart wat onder een bewaarplicht valt.",
"Open in the console": "Openen in de console",
// inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "U hebt geen toegang meer tot {name}. Terug naar uw eigen mail.",
"You can't send from {name}. It was handed to you to read, not to send as.": "U kunt niet verzenden vanuit {name}. Het account is u gegeven om te lezen, niet om namens te verzenden.",
"This account was handed to you to read. Nothing in it can be changed.": "Dit account is u gegeven om te lezen. Er kan niets in worden gewijzigd.",
"You can file and move mail in this account, but not delete it.": "U kunt mail in dit account ordenen en verplaatsen, maar niet verwijderen.",
"Read only": "Alleen lezen",
"Read and organize": "Lezen en ordenen",
"Full access": "Volledige toegang",
"Locked account:": "Vergrendeld account:",
"You can send as this account": "U kunt namens dit account verzenden",
"Back to my mail": "Terug naar mijn mail",
"Send or close the message you're writing first.": "Verzend of sluit eerst het bericht dat u schrijft.",
"Mail to show": "Mail tonen van",
"My mail": "Mijn mail",
"Locked account": "Vergrendeld account",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+134 -125
View File
@@ -1,71 +1,80 @@
import type { PermissionCatalog } from "@/lib/permissionLabels"; import type { PermissionCatalog } from "@/lib/permissionLabels";
/** Stalwart 0.16.22's permission labels in Dutch, keyed by permission name. Checked against source.json. */ /**
* Stalwart 0.16.22's permission labels in Dutch, keyed by permission name. Checked against source.json.
* Reviewed and corrected by Michael (mbjboon82, also mbjboon-netizen), a native speaker, in September 2026: https://git.coffeylabs.org/mbjboon82
*/
// Dutch speaker note: For example “Cluster Nodes Management” is a bit difficult to translate naturally into Dutch.
// “Nodes” is plural, so the Dutch term should also be plural: “Clusterknooppunten”.
// A literal translation such as “Clusterknooppuntbeheer” sounds awkward and also loses the natural plural form.
// For a menu item, “Clusterknooppunten” is much more natural, or “Clusterknooppunten beheren” if the action needs to be explicit.
export const permissionCatalog: PermissionCatalog = { export const permissionCatalog: PermissionCatalog = {
categories: { categories: {
"AI models Management": "AI-modelbeheer", "AI models Management": "AI-modellen beheren",
"API keys Management": "API-sleutelbeheer", "API keys Management": "API-sleutels beheren",
"Accounts Management": "Accountbeheer", "Accounts Management": "Accounts beheren",
"Action": "Actie", "Action": "Actie",
"Actions Management": "Actiebeheer", "Actions Management": "Acties beheren",
"Addresses Management": "Adresbeheer", "Addresses Management": "Adressen beheren",
"Alerts Management": "Waarschuwingsbeheer", "Alerts Management": "Waarschuwingen beheren",
"App passwords Management": "App-wachtwoordbeheer", "App passwords Management": "App-wachtwoorden beheren",
"Applications Management": "Applicatiebeheer", "Applications Management": "Applicaties beheren",
"Archived items Management": "Beheer van gearchiveerde items", "Archived items Management": "Gearchiveerde items beheren",
"Calendar": "Agenda", "Calendar": "Agenda",
"Certificates Management": "Certificaatbeheer", "Certificates Management": "Certificaten beheren",
"Cluster nodes Management": "Clusterknooppuntbeheer", "Cluster nodes Management": "Clusterknooppunten beheren",
"DNS servers Management": "DNS-serverbeheer", "DNS servers Management": "DNS-servers beheren",
"Directories Management": "Adreslijstbeheer", "Directories Management": "Adreslijsten beheren",
"Domains Management": "Domeinbeheer", "Domains Management": "Domeinen beheren",
"Email": "E-mail", "Email": "E-mail",
"Events Management": "Gebeurtenisbeheer", "Events Management": "Gebeurtenissen beheren",
"Extensions Management": "Extensiebeheer", "Extensions Management": "Extensies beheren",
"Hooks Management": "Hookbeheer", "Hooks Management": "Hooks beheren",
"IMAP": "IMAP", "IMAP": "IMAP",
"JMAP": "JMAP", "JMAP": "JMAP",
"Keys Management": "Sleutelbeheer", "Keys Management": "Sleutels beheren",
"Listeners Management": "Listenerbeheer", "Listeners Management": "Listeners beheren",
"Lists Management": "Lijstbeheer", "Lists Management": "Lijsten beheren",
"Log entries Management": "Logboekbeheer", "Log entries Management": "Logboeken beheren",
"Lookups Management": "Lookupbeheer", "Lookups Management": "Lookups beheren",
"Mailing lists Management": "Mailinglijstbeheer", "Mailing lists Management": "Mailinglijsten beheren",
"ManageSieve": "ManageSieve", "ManageSieve": "ManageSieve",
"Masked emails Management": "Beheer van gemaskeerde e-mailadressen", "Masked emails Management": "Gemaskeerde e-mailadressen beheren",
"Messages Management": "Berichtbeheer", "Messages Management": "Berichten beheren",
"Metrics Management": "Statistiekbeheer", "Metrics Management": "Statistieken beheren",
"Milters Management": "Milterbeheer", "Milters Management": "Milters beheren",
"OAuth clients Management": "OAuth-clientbeheer", "OAuth clients Management": "OAuth-clients beheren",
"POP3": "POP3", "POP3": "POP3",
"Providers Management": "Providerbeheer", "Providers Management": "Providers beheren",
"Public keys Management": "Beheer van openbare sleutels", "Public keys Management": "Openbare sleutels beheren",
"Queues Management": "Wachtrijbeheer", "Queues Management": "Wachtrijen beheren",
"Quotas Management": "Quotabeheer", "Quotas Management": "Quotas beheren",
"Reports Management": "Rapportbeheer", "Reports Management": "Rapporten beheren",
"Roles Management": "Rolbeheer", "Roles Management": "Rollen beheren",
"Routes Management": "Routebeheer", "Routes Management": "Routes beheren",
"Rules Management": "Regelbeheer", "Rules Management": "Filterregels beheren",
"Samples Management": "Voorbeeldbeheer", "Samples Management": "Voorbeelden beheren",
"Schedules Management": "Schemabeheer", "Schedules Management": "Schema's beheren",
"Scripts Management": "Scriptbeheer", "Scripts Management": "Scripts beheren",
"Servers Management": "Serverbeheer", "Servers Management": "Servers beheren",
"Settings": "Instellingen", "Settings": "Instellingen",
"Signatures Management": "Handtekeningbeheer", "Signatures Management": "Handtekeningen beheren",
"Strategies Management": "Strategiebeheer", "Strategies Management": "Strategieën beheren",
"Tags Management": "Tagbeheer", "Tags Management": "Tags beheren",
"Task": "Taak", "Task": "Taak",
"Tasks Management": "Takenbeheer", "Tasks Management": "Taken beheren",
"Tenants Management": "Tenantbeheer", "Tenants Management": "Tenants beheren",
"Throttles Management": "Beheer van snelheidsbeperkingen", "Throttles Management": "Snelheidsbeperkingen beheren",
"Tracers Management": "Tracerbeheer", "Tracers Management": "Tracers beheren",
"Traces Management": "Tracebeheer", "Traces Management": "Traces beheren",
"WebDAV": "WebDAV", "WebDAV": "WebDAV",
"Webhooks Management": "Webhookbeheer", "Webhooks Management": "Webhooks beheren",
}, },
labels: { labels: {
authenticate: "Inloggen op de server", authenticate: "Inloggen op de server",
authenticateWithAlias: "Inloggen met e-mailaliasadressen", authenticateWithAlias: "Inloggen met e-mailalias-adressen",
interactAi: "AI-modellen gebruiken", interactAi: "AI-modellen gebruiken",
impersonate: "Namens een andere gebruiker handelen", impersonate: "Namens een andere gebruiker handelen",
unlimitedRequests: "Snelheidslimieten voor verzoeken omzeilen", unlimitedRequests: "Snelheidslimieten voor verzoeken omzeilen",
@@ -283,23 +292,23 @@ export const permissionCatalog: PermissionCatalog = {
oAuthClientOverride: "Registratievereisten voor OAuth-clients omzeilen", oAuthClientOverride: "Registratievereisten voor OAuth-clients omzeilen",
liveTracing: "Realtime traces van servergebeurtenissen bekijken", liveTracing: "Realtime traces van servergebeurtenissen bekijken",
liveMetrics: "Realtime prestatiestatistieken van de server bekijken", liveMetrics: "Realtime prestatiestatistieken van de server bekijken",
liveDeliveryTest: "Test-e-mails verzenden om de bezorgconfiguratie te controleren", liveDeliveryTest: "Test e-mails verzenden om de bezorgconfiguratie te controleren",
scimAccess: "Gebruikers en groepen inrichten via het SCIM-eindpunt", scimAccess: "Gebruikers en groepen inrichten via het SCIM-eindpunt",
sysAccountGet: "Accounts ophalen", sysAccountGet: "Accounts ophalen",
sysAccountCreate: "Accounts aanmaken", sysAccountCreate: "Accounts aanmaken",
sysAccountUpdate: "Accounts bijwerken", sysAccountUpdate: "Accounts bijwerken",
sysAccountDestroy: "Accounts verwijderen", sysAccountDestroy: "Accounts verwijderen",
sysAccountQuery: "Accounts opvragen", sysAccountQuery: "Accounts opvragen",
sysAccountPasswordGet: "Wachtwoordinstellingen van accounts ophalen", sysAccountPasswordGet: "Wachtwoord instellingen van accounts ophalen",
sysAccountPasswordUpdate: "Wachtwoordinstellingen van accounts bijwerken", sysAccountPasswordUpdate: "Wachtwoord instellingen van accounts bijwerken",
sysAccountSettingsGet: "Accountinstellingen ophalen", sysAccountSettingsGet: "Account instellingen ophalen",
sysAccountSettingsUpdate: "Accountinstellingen bijwerken", sysAccountSettingsUpdate: "Account instellingen bijwerken",
sysAcmeProviderGet: "Providers ophalen", sysAcmeProviderGet: "Providers ophalen",
sysAcmeProviderCreate: "Providers aanmaken", sysAcmeProviderCreate: "Providers aanmaken",
sysAcmeProviderUpdate: "Providers bijwerken", sysAcmeProviderUpdate: "Providers bijwerken",
sysAcmeProviderDestroy: "Providers verwijderen", sysAcmeProviderDestroy: "Providers verwijderen",
sysAcmeProviderQuery: "Providers opvragen", sysAcmeProviderQuery: "Providers opvragen",
actionReloadSettings: "Opnieuw laden: serverinstellingen", actionReloadSettings: "Opnieuw laden: server instellingen",
actionReloadTlsCertificates: "Opnieuw laden: TLS-certificaten", actionReloadTlsCertificates: "Opnieuw laden: TLS-certificaten",
actionReloadLookupStores: "Opnieuw laden: lookup-opslag", actionReloadLookupStores: "Opnieuw laden: lookup-opslag",
actionReloadBlockedIps: "Opnieuw laden: lijst met geblokkeerde IP's", actionReloadBlockedIps: "Opnieuw laden: lijst met geblokkeerde IP's",
@@ -315,8 +324,8 @@ export const permissionCatalog: PermissionCatalog = {
sysActionUpdate: "Acties bijwerken", sysActionUpdate: "Acties bijwerken",
sysActionDestroy: "Acties verwijderen", sysActionDestroy: "Acties verwijderen",
sysActionQuery: "Acties opvragen", sysActionQuery: "Acties opvragen",
sysAddressBookGet: "Adresboekinstellingen ophalen", sysAddressBookGet: "Adresboek instellingen ophalen",
sysAddressBookUpdate: "Adresboekinstellingen bijwerken", sysAddressBookUpdate: "Adresboek instellingen bijwerken",
sysAiModelGet: "AI-modellen ophalen", sysAiModelGet: "AI-modellen ophalen",
sysAiModelCreate: "AI-modellen aanmaken", sysAiModelCreate: "AI-modellen aanmaken",
sysAiModelUpdate: "AI-modellen bijwerken", sysAiModelUpdate: "AI-modellen bijwerken",
@@ -357,23 +366,23 @@ export const permissionCatalog: PermissionCatalog = {
sysArfExternalReportUpdate: "Rapporten bijwerken", sysArfExternalReportUpdate: "Rapporten bijwerken",
sysArfExternalReportDestroy: "Rapporten verwijderen", sysArfExternalReportDestroy: "Rapporten verwijderen",
sysArfExternalReportQuery: "Rapporten opvragen", sysArfExternalReportQuery: "Rapporten opvragen",
sysAsnGet: "ASN-instellingen ophalen", sysAsnGet: "ASN instellingen ophalen",
sysAsnUpdate: "ASN-instellingen bijwerken", sysAsnUpdate: "ASN instellingen bijwerken",
sysAuthenticationGet: "Authenticatie-instellingen ophalen", sysAuthenticationGet: "Authenticatie instellingen ophalen",
sysAuthenticationUpdate: "Authenticatie-instellingen bijwerken", sysAuthenticationUpdate: "Authenticatie instellingen bijwerken",
sysBlobStoreGet: "Blob-opslaginstellingen ophalen", sysBlobStoreGet: "Blob-opslag instellingen ophalen",
sysBlobStoreUpdate: "Blob-opslaginstellingen bijwerken", sysBlobStoreUpdate: "Blob-opslag instellingen bijwerken",
sysBlockedIpGet: "Adressen ophalen", sysBlockedIpGet: "Adressen ophalen",
sysBlockedIpCreate: "Adressen aanmaken", sysBlockedIpCreate: "Adressen aanmaken",
sysBlockedIpUpdate: "Adressen bijwerken", sysBlockedIpUpdate: "Adressen bijwerken",
sysBlockedIpDestroy: "Adressen verwijderen", sysBlockedIpDestroy: "Adressen verwijderen",
sysBlockedIpQuery: "Adressen opvragen", sysBlockedIpQuery: "Adressen opvragen",
sysBootstrapGet: "Bootstrap-instellingen ophalen", sysBootstrapGet: "Bootstrap instellingen ophalen",
sysBootstrapUpdate: "Bootstrap-instellingen bijwerken", sysBootstrapUpdate: "Bootstrap instellingen bijwerken",
sysCacheGet: "Cache-instellingen ophalen", sysCacheGet: "Cache instellingen ophalen",
sysCacheUpdate: "Cache-instellingen bijwerken", sysCacheUpdate: "Cache instellingen bijwerken",
sysCalendarGet: "Agenda-instellingen ophalen", sysCalendarGet: "Agenda instellingen ophalen",
sysCalendarUpdate: "Agenda-instellingen bijwerken", sysCalendarUpdate: "Agenda instellingen bijwerken",
sysCalendarAlarmGet: "Instellingen voor agendaherinneringen ophalen", sysCalendarAlarmGet: "Instellingen voor agendaherinneringen ophalen",
sysCalendarAlarmUpdate: "Instellingen voor agendaherinneringen bijwerken", sysCalendarAlarmUpdate: "Instellingen voor agendaherinneringen bijwerken",
sysCalendarSchedulingGet: "Instellingen voor agendaplanning ophalen", sysCalendarSchedulingGet: "Instellingen voor agendaplanning ophalen",
@@ -393,19 +402,19 @@ export const permissionCatalog: PermissionCatalog = {
sysClusterRoleUpdate: "Rollen bijwerken", sysClusterRoleUpdate: "Rollen bijwerken",
sysClusterRoleDestroy: "Rollen verwijderen", sysClusterRoleDestroy: "Rollen verwijderen",
sysClusterRoleQuery: "Rollen opvragen", sysClusterRoleQuery: "Rollen opvragen",
sysCoordinatorGet: "Coördinatorinstellingen ophalen", sysCoordinatorGet: "Coördinator instellingen ophalen",
sysCoordinatorUpdate: "Coördinatorinstellingen bijwerken", sysCoordinatorUpdate: "Coördinator instellingen bijwerken",
sysDataRetentionGet: "Instellingen voor gegevensbewaring ophalen", sysDataRetentionGet: "Instellingen voor gegevensbewaring ophalen",
sysDataRetentionUpdate: "Instellingen voor gegevensbewaring bijwerken", sysDataRetentionUpdate: "Instellingen voor gegevensbewaring bijwerken",
sysDataStoreGet: "Gegevensopslaginstellingen ophalen", sysDataStoreGet: "Gegevensopslag instellingen ophalen",
sysDataStoreUpdate: "Gegevensopslaginstellingen bijwerken", sysDataStoreUpdate: "Gegevensopslag instellingen bijwerken",
sysDirectoryGet: "Adreslijsten ophalen", sysDirectoryGet: "Adreslijsten ophalen",
sysDirectoryCreate: "Adreslijsten aanmaken", sysDirectoryCreate: "Adreslijsten aanmaken",
sysDirectoryUpdate: "Adreslijsten bijwerken", sysDirectoryUpdate: "Adreslijsten bijwerken",
sysDirectoryDestroy: "Adreslijsten verwijderen", sysDirectoryDestroy: "Adreslijsten verwijderen",
sysDirectoryQuery: "Adreslijsten opvragen", sysDirectoryQuery: "Adreslijsten opvragen",
sysDkimReportSettingsGet: "DKIM-rapportinstellingen ophalen", sysDkimReportSettingsGet: "DKIM-rapport instellingen ophalen",
sysDkimReportSettingsUpdate: "DKIM-rapportinstellingen bijwerken", sysDkimReportSettingsUpdate: "DKIM-rapport instellingen bijwerken",
sysDkimSignatureGet: "Handtekeningen ophalen", sysDkimSignatureGet: "Handtekeningen ophalen",
sysDkimSignatureCreate: "Handtekeningen aanmaken", sysDkimSignatureCreate: "Handtekeningen aanmaken",
sysDkimSignatureUpdate: "Handtekeningen bijwerken", sysDkimSignatureUpdate: "Handtekeningen bijwerken",
@@ -421,10 +430,10 @@ export const permissionCatalog: PermissionCatalog = {
sysDmarcInternalReportUpdate: "Rapporten bijwerken", sysDmarcInternalReportUpdate: "Rapporten bijwerken",
sysDmarcInternalReportDestroy: "Rapporten verwijderen", sysDmarcInternalReportDestroy: "Rapporten verwijderen",
sysDmarcInternalReportQuery: "Rapporten opvragen", sysDmarcInternalReportQuery: "Rapporten opvragen",
sysDmarcReportSettingsGet: "DMARC-rapportinstellingen ophalen", sysDmarcReportSettingsGet: "DMARC-rapport instellingen ophalen",
sysDmarcReportSettingsUpdate: "DMARC-rapportinstellingen bijwerken", sysDmarcReportSettingsUpdate: "DMARC-rapport instellingen bijwerken",
sysDnsResolverGet: "DNS-resolverinstellingen ophalen", sysDnsResolverGet: "DNS-resolver instellingen ophalen",
sysDnsResolverUpdate: "DNS-resolverinstellingen bijwerken", sysDnsResolverUpdate: "DNS-resolver instellingen bijwerken",
sysDnsServerGet: "DNS-servers ophalen", sysDnsServerGet: "DNS-servers ophalen",
sysDnsServerCreate: "DNS-servers aanmaken", sysDnsServerCreate: "DNS-servers aanmaken",
sysDnsServerUpdate: "DNS-servers bijwerken", sysDnsServerUpdate: "DNS-servers bijwerken",
@@ -435,34 +444,34 @@ export const permissionCatalog: PermissionCatalog = {
sysDomainUpdate: "Domeinen bijwerken", sysDomainUpdate: "Domeinen bijwerken",
sysDomainDestroy: "Domeinen verwijderen", sysDomainDestroy: "Domeinen verwijderen",
sysDomainQuery: "Domeinen opvragen", sysDomainQuery: "Domeinen opvragen",
sysDsnReportSettingsGet: "DSN-rapportinstellingen ophalen", sysDsnReportSettingsGet: "DSN-rapport instellingen ophalen",
sysDsnReportSettingsUpdate: "DSN-rapportinstellingen bijwerken", sysDsnReportSettingsUpdate: "DSN-rapport instellingen bijwerken",
sysEmailGet: "E-mailinstellingen ophalen", sysEmailGet: "E-mail instellingen ophalen",
sysEmailUpdate: "E-mailinstellingen bijwerken", sysEmailUpdate: "E-mail instellingen bijwerken",
sysEnterpriseGet: "Enterprise-instellingen ophalen", sysEnterpriseGet: "Enterprise instellingen ophalen",
sysEnterpriseUpdate: "Enterprise-instellingen bijwerken", sysEnterpriseUpdate: "Enterprise instellingen bijwerken",
sysEventTracingLevelGet: "Gebeurtenissen ophalen", sysEventTracingLevelGet: "Gebeurtenissen ophalen",
sysEventTracingLevelCreate: "Gebeurtenissen aanmaken", sysEventTracingLevelCreate: "Gebeurtenissen aanmaken",
sysEventTracingLevelUpdate: "Gebeurtenissen bijwerken", sysEventTracingLevelUpdate: "Gebeurtenissen bijwerken",
sysEventTracingLevelDestroy: "Gebeurtenissen verwijderen", sysEventTracingLevelDestroy: "Gebeurtenissen verwijderen",
sysEventTracingLevelQuery: "Gebeurtenissen opvragen", sysEventTracingLevelQuery: "Gebeurtenissen opvragen",
sysFileStorageGet: "Bestandsopslaginstellingen ophalen", sysFileStorageGet: "Bestandsopslag instellingen ophalen",
sysFileStorageUpdate: "Bestandsopslaginstellingen bijwerken", sysFileStorageUpdate: "Bestandsopslag instellingen bijwerken",
sysHttpGet: "HTTP-instellingen ophalen", sysHttpGet: "HTTP instellingen ophalen",
sysHttpUpdate: "HTTP-instellingen bijwerken", sysHttpUpdate: "HTTP instellingen bijwerken",
sysHttpFormGet: "HTTP-formulierinstellingen ophalen", sysHttpFormGet: "HTTP-formulier instellingen ophalen",
sysHttpFormUpdate: "HTTP-formulierinstellingen bijwerken", sysHttpFormUpdate: "HTTP-formulier instellingen bijwerken",
sysHttpLookupGet: "Lijsten ophalen", sysHttpLookupGet: "Lijsten ophalen",
sysHttpLookupCreate: "Lijsten aanmaken", sysHttpLookupCreate: "Lijsten aanmaken",
sysHttpLookupUpdate: "Lijsten bijwerken", sysHttpLookupUpdate: "Lijsten bijwerken",
sysHttpLookupDestroy: "Lijsten verwijderen", sysHttpLookupDestroy: "Lijsten verwijderen",
sysHttpLookupQuery: "Lijsten opvragen", sysHttpLookupQuery: "Lijsten opvragen",
sysImapGet: "IMAP-instellingen ophalen", sysImapGet: "IMAP instellingen ophalen",
sysImapUpdate: "IMAP-instellingen bijwerken", sysImapUpdate: "IMAP instellingen bijwerken",
sysInMemoryStoreGet: "Instellingen voor in-memory-opslag ophalen", sysInMemoryStoreGet: "Instellingen voor in-memory-opslag ophalen",
sysInMemoryStoreUpdate: "Instellingen voor in-memory-opslag bijwerken", sysInMemoryStoreUpdate: "Instellingen voor in-memory-opslag bijwerken",
sysJmapGet: "JMAP-instellingen ophalen", sysJmapGet: "JMAP instellingen ophalen",
sysJmapUpdate: "JMAP-instellingen bijwerken", sysJmapUpdate: "JMAP instellingen bijwerken",
sysLogGet: "Logboekvermeldingen ophalen", sysLogGet: "Logboekvermeldingen ophalen",
sysLogCreate: "Logboekvermeldingen aanmaken", sysLogCreate: "Logboekvermeldingen aanmaken",
sysLogUpdate: "Logboekvermeldingen bijwerken", sysLogUpdate: "Logboekvermeldingen bijwerken",
@@ -493,8 +502,8 @@ export const permissionCatalog: PermissionCatalog = {
sysMetricUpdate: "Statistieken bijwerken", sysMetricUpdate: "Statistieken bijwerken",
sysMetricDestroy: "Statistieken verwijderen", sysMetricDestroy: "Statistieken verwijderen",
sysMetricQuery: "Statistieken opvragen", sysMetricQuery: "Statistieken opvragen",
sysMetricsGet: "Statistiekinstellingen ophalen", sysMetricsGet: "Statistiek instellingen ophalen",
sysMetricsUpdate: "Statistiekinstellingen bijwerken", sysMetricsUpdate: "Statistiek instellingen bijwerken",
sysMetricsStoreGet: "Instellingen voor statistiekopslag ophalen", sysMetricsStoreGet: "Instellingen voor statistiekopslag ophalen",
sysMetricsStoreUpdate: "Instellingen voor statistiekopslag bijwerken", sysMetricsStoreUpdate: "Instellingen voor statistiekopslag bijwerken",
sysMtaConnectionStrategyGet: "Strategieën ophalen", sysMtaConnectionStrategyGet: "Strategieën ophalen",
@@ -507,8 +516,8 @@ export const permissionCatalog: PermissionCatalog = {
sysMtaDeliveryScheduleUpdate: "Schema's bijwerken", sysMtaDeliveryScheduleUpdate: "Schema's bijwerken",
sysMtaDeliveryScheduleDestroy: "Schema's verwijderen", sysMtaDeliveryScheduleDestroy: "Schema's verwijderen",
sysMtaDeliveryScheduleQuery: "Schema's opvragen", sysMtaDeliveryScheduleQuery: "Schema's opvragen",
sysMtaExtensionsGet: "MTA-extensie-instellingen ophalen", sysMtaExtensionsGet: "MTA-extensie instellingen ophalen",
sysMtaExtensionsUpdate: "MTA-extensie-instellingen bijwerken", sysMtaExtensionsUpdate: "MTA-extensie instellingen bijwerken",
sysMtaHookGet: "Hooks ophalen", sysMtaHookGet: "Hooks ophalen",
sysMtaHookCreate: "Hooks aanmaken", sysMtaHookCreate: "Hooks aanmaken",
sysMtaHookUpdate: "Hooks bijwerken", sysMtaHookUpdate: "Hooks bijwerken",
@@ -555,8 +564,8 @@ export const permissionCatalog: PermissionCatalog = {
sysMtaStageMailUpdate: "Instellingen voor MTA-fase MAIL bijwerken", sysMtaStageMailUpdate: "Instellingen voor MTA-fase MAIL bijwerken",
sysMtaStageRcptGet: "Instellingen voor MTA-fase RCPT ophalen", sysMtaStageRcptGet: "Instellingen voor MTA-fase RCPT ophalen",
sysMtaStageRcptUpdate: "Instellingen voor MTA-fase RCPT bijwerken", sysMtaStageRcptUpdate: "Instellingen voor MTA-fase RCPT bijwerken",
sysMtaStsGet: "MTA-STS-instellingen ophalen", sysMtaStsGet: "MTA-STS instellingen ophalen",
sysMtaStsUpdate: "MTA-STS-instellingen bijwerken", sysMtaStsUpdate: "MTA-STS instellingen bijwerken",
sysMtaTlsStrategyGet: "Strategieën ophalen", sysMtaTlsStrategyGet: "Strategieën ophalen",
sysMtaTlsStrategyCreate: "Strategieën aanmaken", sysMtaTlsStrategyCreate: "Strategieën aanmaken",
sysMtaTlsStrategyUpdate: "Strategieën bijwerken", sysMtaTlsStrategyUpdate: "Strategieën bijwerken",
@@ -577,8 +586,8 @@ export const permissionCatalog: PermissionCatalog = {
sysOAuthClientUpdate: "OAuth-clients bijwerken", sysOAuthClientUpdate: "OAuth-clients bijwerken",
sysOAuthClientDestroy: "OAuth-clients verwijderen", sysOAuthClientDestroy: "OAuth-clients verwijderen",
sysOAuthClientQuery: "OAuth-clients opvragen", sysOAuthClientQuery: "OAuth-clients opvragen",
sysOidcProviderGet: "OIDC-providerinstellingen ophalen", sysOidcProviderGet: "OIDC-provider instellingen ophalen",
sysOidcProviderUpdate: "OIDC-providerinstellingen bijwerken", sysOidcProviderUpdate: "OIDC-provider instellingen bijwerken",
sysPublicKeyGet: "Openbare sleutels ophalen", sysPublicKeyGet: "Openbare sleutels ophalen",
sysPublicKeyCreate: "Openbare sleutels aanmaken", sysPublicKeyCreate: "Openbare sleutels aanmaken",
sysPublicKeyUpdate: "Openbare sleutels bijwerken", sysPublicKeyUpdate: "Openbare sleutels bijwerken",
@@ -589,19 +598,19 @@ export const permissionCatalog: PermissionCatalog = {
sysQueuedMessageUpdate: "Berichten bijwerken", sysQueuedMessageUpdate: "Berichten bijwerken",
sysQueuedMessageDestroy: "Berichten verwijderen", sysQueuedMessageDestroy: "Berichten verwijderen",
sysQueuedMessageQuery: "Berichten opvragen", sysQueuedMessageQuery: "Berichten opvragen",
sysReportSettingsGet: "Rapportinstellingen ophalen", sysReportSettingsGet: "Rapport instellingen ophalen",
sysReportSettingsUpdate: "Rapportinstellingen bijwerken", sysReportSettingsUpdate: "Rapport instellingen bijwerken",
sysRoleGet: "Rollen ophalen", sysRoleGet: "Rollen ophalen",
sysRoleCreate: "Rollen aanmaken", sysRoleCreate: "Rollen aanmaken",
sysRoleUpdate: "Rollen bijwerken", sysRoleUpdate: "Rollen bijwerken",
sysRoleDestroy: "Rollen verwijderen", sysRoleDestroy: "Rollen verwijderen",
sysRoleQuery: "Rollen opvragen", sysRoleQuery: "Rollen opvragen",
sysSearchGet: "Zoekinstellingen ophalen", sysSearchGet: "Zoek instellingen ophalen",
sysSearchUpdate: "Zoekinstellingen bijwerken", sysSearchUpdate: "Zoek instellingen bijwerken",
sysSearchStoreGet: "Instellingen voor zoekopslag ophalen", sysSearchStoreGet: "Instellingen voor zoekopslag ophalen",
sysSearchStoreUpdate: "Instellingen voor zoekopslag bijwerken", sysSearchStoreUpdate: "Instellingen voor zoekopslag bijwerken",
sysSecurityGet: "Beveiligingsinstellingen ophalen", sysSecurityGet: "Beveiliging instellingen ophalen",
sysSecurityUpdate: "Beveiligingsinstellingen bijwerken", sysSecurityUpdate: "Beveiliging instellingen bijwerken",
sysSenderAuthGet: "Instellingen voor afzenderauthenticatie ophalen", sysSenderAuthGet: "Instellingen voor afzenderauthenticatie ophalen",
sysSenderAuthUpdate: "Instellingen voor afzenderauthenticatie bijwerken", sysSenderAuthUpdate: "Instellingen voor afzenderauthenticatie bijwerken",
sysSharingGet: "Instellingen voor delen ophalen", sysSharingGet: "Instellingen voor delen ophalen",
@@ -634,17 +643,17 @@ export const permissionCatalog: PermissionCatalog = {
sysSpamFileExtensionUpdate: "Extensies bijwerken", sysSpamFileExtensionUpdate: "Extensies bijwerken",
sysSpamFileExtensionDestroy: "Extensies verwijderen", sysSpamFileExtensionDestroy: "Extensies verwijderen",
sysSpamFileExtensionQuery: "Extensies opvragen", sysSpamFileExtensionQuery: "Extensies opvragen",
sysSpamLlmGet: "Spam-LLM-instellingen ophalen", sysSpamLlmGet: "Spam-LLM instellingen ophalen",
sysSpamLlmUpdate: "Spam-LLM-instellingen bijwerken", sysSpamLlmUpdate: "Spam-LLM instellingen bijwerken",
sysSpamPyzorGet: "Spam-Pyzor-instellingen ophalen", sysSpamPyzorGet: "Spam-Pyzor instellingen ophalen",
sysSpamPyzorUpdate: "Spam-Pyzor-instellingen bijwerken", sysSpamPyzorUpdate: "Spam-Pyzor instellingen bijwerken",
sysSpamRuleGet: "Regels ophalen", sysSpamRuleGet: "Regels ophalen",
sysSpamRuleCreate: "Regels aanmaken", sysSpamRuleCreate: "Regels aanmaken",
sysSpamRuleUpdate: "Regels bijwerken", sysSpamRuleUpdate: "Regels bijwerken",
sysSpamRuleDestroy: "Regels verwijderen", sysSpamRuleDestroy: "Regels verwijderen",
sysSpamRuleQuery: "Regels opvragen", sysSpamRuleQuery: "Regels opvragen",
sysSpamSettingsGet: "Spaminstellingen ophalen", sysSpamSettingsGet: "Spam instellingen ophalen",
sysSpamSettingsUpdate: "Spaminstellingen bijwerken", sysSpamSettingsUpdate: "Spam instellingen bijwerken",
sysSpamTagGet: "Tags ophalen", sysSpamTagGet: "Tags ophalen",
sysSpamTagCreate: "Tags aanmaken", sysSpamTagCreate: "Tags aanmaken",
sysSpamTagUpdate: "Tags bijwerken", sysSpamTagUpdate: "Tags bijwerken",
@@ -655,15 +664,15 @@ export const permissionCatalog: PermissionCatalog = {
sysSpamTrainingSampleUpdate: "Voorbeelden bijwerken", sysSpamTrainingSampleUpdate: "Voorbeelden bijwerken",
sysSpamTrainingSampleDestroy: "Voorbeelden verwijderen", sysSpamTrainingSampleDestroy: "Voorbeelden verwijderen",
sysSpamTrainingSampleQuery: "Voorbeelden opvragen", sysSpamTrainingSampleQuery: "Voorbeelden opvragen",
sysSpfReportSettingsGet: "SPF-rapportinstellingen ophalen", sysSpfReportSettingsGet: "SPF-rapport instellingen ophalen",
sysSpfReportSettingsUpdate: "SPF-rapportinstellingen bijwerken", sysSpfReportSettingsUpdate: "SPF-rapport instellingen bijwerken",
sysStoreLookupGet: "Lookups ophalen", sysStoreLookupGet: "Lookups ophalen",
sysStoreLookupCreate: "Lookups aanmaken", sysStoreLookupCreate: "Lookups aanmaken",
sysStoreLookupUpdate: "Lookups bijwerken", sysStoreLookupUpdate: "Lookups bijwerken",
sysStoreLookupDestroy: "Lookups verwijderen", sysStoreLookupDestroy: "Lookups verwijderen",
sysStoreLookupQuery: "Lookups opvragen", sysStoreLookupQuery: "Lookups opvragen",
sysSystemSettingsGet: "Systeeminstellingen ophalen", sysSystemSettingsGet: "Systeem instellingen ophalen",
sysSystemSettingsUpdate: "Systeeminstellingen bijwerken", sysSystemSettingsUpdate: "Systeem instellingen bijwerken",
taskIndexDocument: "Document indexeren", taskIndexDocument: "Document indexeren",
taskUnindexDocument: "Document uit de index verwijderen", taskUnindexDocument: "Document uit de index verwijderen",
taskIndexTrace: "Telemetrietrace indexeren", taskIndexTrace: "Telemetrietrace indexeren",
@@ -704,8 +713,8 @@ export const permissionCatalog: PermissionCatalog = {
sysTlsInternalReportUpdate: "Rapporten bijwerken", sysTlsInternalReportUpdate: "Rapporten bijwerken",
sysTlsInternalReportDestroy: "Rapporten verwijderen", sysTlsInternalReportDestroy: "Rapporten verwijderen",
sysTlsInternalReportQuery: "Rapporten opvragen", sysTlsInternalReportQuery: "Rapporten opvragen",
sysTlsReportSettingsGet: "TLS-rapportinstellingen ophalen", sysTlsReportSettingsGet: "TLS-rapport instellingen ophalen",
sysTlsReportSettingsUpdate: "TLS-rapportinstellingen bijwerken", sysTlsReportSettingsUpdate: "TLS-rapport instellingen bijwerken",
sysTraceGet: "Traces ophalen", sysTraceGet: "Traces ophalen",
sysTraceCreate: "Traces aanmaken", sysTraceCreate: "Traces aanmaken",
sysTraceUpdate: "Traces bijwerken", sysTraceUpdate: "Traces bijwerken",
@@ -718,8 +727,8 @@ export const permissionCatalog: PermissionCatalog = {
sysTracerQuery: "Tracers opvragen", sysTracerQuery: "Tracers opvragen",
sysTracingStoreGet: "Instellingen voor tracing-opslag ophalen", sysTracingStoreGet: "Instellingen voor tracing-opslag ophalen",
sysTracingStoreUpdate: "Instellingen voor tracing-opslag bijwerken", sysTracingStoreUpdate: "Instellingen voor tracing-opslag bijwerken",
sysWebDavGet: "WebDAV-instellingen ophalen", sysWebDavGet: "WebDAV instellingen ophalen",
sysWebDavUpdate: "WebDAV-instellingen bijwerken", sysWebDavUpdate: "WebDAV instellingen bijwerken",
sysWebHookGet: "Webhooks ophalen", sysWebHookGet: "Webhooks ophalen",
sysWebHookCreate: "Webhooks aanmaken", sysWebHookCreate: "Webhooks aanmaken",
sysWebHookUpdate: "Webhooks bijwerken", sysWebHookUpdate: "Webhooks bijwerken",
+729
View File
@@ -0,0 +1,729 @@
import type { PermissionCatalog } from "@/lib/permissionLabels";
/** Stalwart 0.16.22's permission labels in Turkish, keyed by permission name. Checked against source.json. */
export const permissionCatalog: PermissionCatalog = {
categories: {
"AI models Management": "Yapay Zeka Modelleri Yönetimi",
"API keys Management": "API Anahtarları Yönetimi",
"Accounts Management": "Hesap Yönetimi",
"Action": "Eylem",
"Actions Management": "Eylem Yönetimi",
"Addresses Management": "Adres Yönetimi",
"Alerts Management": "Uyarı Yönetimi",
"App passwords Management": "Uygulama Şifreleri Yönetimi",
"Applications Management": "Uygulama Yönetimi",
"Archived items Management": "Arşivlenmiş Ögeler Yönetimi",
"Calendar": "Takvim",
"Certificates Management": "Sertifika Yönetimi",
"Cluster nodes Management": "Küme Düğümleri Yönetimi",
"DNS servers Management": "DNS Sunucuları Yönetimi",
"Directories Management": "Dizin Yönetimi",
"Domains Management": "Alan Adı Yönetimi",
"Email": "E-posta",
"Events Management": "Etkinlik Yönetimi",
"Extensions Management": "Uzantı Yönetimi",
"Hooks Management": "Kanca (Hook) Yönetimi",
"IMAP": "IMAP",
"JMAP": "JMAP",
"Keys Management": "Anahtar Yönetimi",
"Listeners Management": "Dinleyici (Listener) Yönetimi",
"Lists Management": "Liste Yönetimi",
"Log entries Management": "Günlük Kayıtları Yönetimi",
"Lookups Management": "Arama (Lookup) Yönetimi",
"Mailing lists Management": "E-posta Listeleri Yönetimi",
"ManageSieve": "ManageSieve",
"Masked emails Management": "Maskelenmiş E-posta Yönetimi",
"Messages Management": "İleti Yönetimi",
"Metrics Management": "Metrik Yönetimi",
"Milters Management": "Milter Yönetimi",
"OAuth clients Management": "OAuth İstemcileri Yönetimi",
"POP3": "POP3",
"Providers Management": "Sağlayıcı Yönetimi",
"Public keys Management": "Genel Anahtar Yönetimi",
"Queues Management": "Kuyruk Yönetimi",
"Quotas Management": "Kota Yönetimi",
"Reports Management": "Rapor Yönetimi",
"Roles Management": "Rol Yönetimi",
"Routes Management": "Rota Yönetimi",
"Rules Management": "Kural Yönetimi",
"Samples Management": "Örnek Yönetimi",
"Schedules Management": "Zamanlama Yönetimi",
"Scripts Management": "Betik Yönetimi",
"Servers Management": "Sunucu Yönetimi",
"Settings": "Ayarlar",
"Signatures Management": "İmza Yönetimi",
"Strategies Management": "Strateji Yönetimi",
"Tags Management": "Etiket Yönetimi",
"Task": "Görev",
"Tasks Management": "Görev Yönetimi",
"Tenants Management": "Kiracı (Tenant) Yönetimi",
"Throttles Management": "Hız Sınırlama (Throttle) Yönetimi",
"Tracers Management": "İzleyici (Tracer) Yönetimi",
"Traces Management": "İzleme Kayıtları (Trace) Yönetimi",
"WebDAV": "WebDAV",
"Webhooks Management": "Webhook Yönetimi",
},
labels: {
authenticate: "Sunucuya giriş yap",
authenticateWithAlias: "E-posta takma adları kullanarak giriş yap",
interactAi: "Yapay zeka modelleriyle etkileşim kur",
impersonate: "Başka bir kullanıcı adına işlem yap",
unlimitedRequests: "İstek hız sınırlarını atla",
unlimitedUploads: "Yükleme boyutu ve hız sınırlarını atla",
fetchAnyBlob: "Hesaba ait olmayanlar dahil herhangi bir blob nesnesini al",
emailSend: "E-postaları gönder",
emailReceive: "E-postaları al",
calendarAlarmsSend: "Takvim anımsatıcı bildirimlerini e-posta ile gönder",
calendarSchedulingSend: "Takvim davetlerini ve güncellemelerini e-posta ile gönder",
calendarSchedulingReceive: "Gelen takvim davetlerini ve yanıtlarını işle",
jmapPushSubscriptionGet: "Push aboneliğini ayrıntılarını al",
jmapPushSubscriptionCreate: "Yeni push aboneliklerini oluştur",
jmapPushSubscriptionUpdate: "Mevcut push aboneliklerini düzenle",
jmapPushSubscriptionDestroy: "Push aboneliklerini kaldır",
jmapMailboxGet: "Posta kutusunu ayrıntılarını ve özelliklerini al",
jmapMailboxChanges: "Belirli bir durumdan bu yana posta kutularını üzerindeki değişiklikleri izle",
jmapMailboxQuery: "Kriterlerle eşleşen posta kutularını ara",
jmapMailboxQueryChanges: "Posta kutusunu sorgu sonuçlarındaki değişiklikleri izle",
jmapMailboxCreate: "Yeni posta kutularını oluştur",
jmapMailboxUpdate: "Mevcut posta kutularını düzenle",
jmapMailboxDestroy: "Posta kutularını kaldır",
jmapThreadGet: "Email thread ayrıntılarını al",
jmapThreadChanges: "Belirli bir durumdan bu yana yazışmaları üzerindeki değişiklikleri izle",
jmapEmailGet: "E-postayı ayrıntılarını ve özelliklerini al",
jmapEmailChanges: "Belirli bir durumdan bu yana e-postaları üzerindeki değişiklikleri izle",
jmapEmailQuery: "Kriterlerle eşleşen e-postaları ara",
jmapEmailQueryChanges: "E-postayı sorgu sonuçlarındaki değişiklikleri izle",
jmapEmailCreate: "Yeni e-postaları oluştur",
jmapEmailUpdate: "Mevcut e-postaları düzenle",
jmapEmailDestroy: "E-postaları kaldır",
jmapEmailCopy: "E-postaları başka bir hesaba kopyala",
jmapEmailImport: "Blob verilerinden e-postaları içe aktar",
jmapEmailParse: "Ham e-posta verilerini kaydetmeden ayrıştır",
jmapSearchSnippetGet: "E-postalar için arama sonucu pasajlarını al",
jmapIdentityGet: "Gönderen kimliğini ayrıntılarını al",
jmapIdentityChanges: "Belirli bir durumdan bu yana kimlikleri üzerindeki değişiklikleri izle",
jmapIdentityCreate: "Yeni gönderen kimliklerini oluştur",
jmapIdentityUpdate: "Mevcut gönderen kimliklerini düzenle",
jmapIdentityDestroy: "Gönderen kimliklerini kaldır",
jmapEmailSubmissionGet: "E-posta gönderimini ayrıntılarını al",
jmapEmailSubmissionChanges: "Belirli bir durumdan bu yana e-posta gönderimlerini üzerindeki değişiklikleri izle",
jmapEmailSubmissionQuery: "Kriterlerle eşleşen e-posta gönderimlerini ara",
jmapEmailSubmissionQueryChanges: "E-posta gönderimini sorgu sonuçlarındaki değişiklikleri izle",
jmapEmailSubmissionCreate: "Yeni email submissions for sending oluştur",
jmapEmailSubmissionUpdate: "Mevcut e-posta gönderimlerini düzenle",
jmapEmailSubmissionDestroy: "E-posta gönderimlerini kaldır",
jmapVacationResponseGet: "Tatil yanıtı yapılandırmasını al",
jmapVacationResponseCreate: "Tatil yanıtı ayarlarını oluştur",
jmapVacationResponseUpdate: "Tatil / otomatik yanıt ayarlarını düzenle",
jmapVacationResponseDestroy: "Tatil yanıtı ayarlarını kaldır",
jmapSieveScriptGet: "Sieve filtre betiğini ayrıntılarını al",
jmapSieveScriptQuery: "Kriterlerle eşleşen Sieve betiklerini ara",
jmapSieveScriptValidate: "Bir Sieve betiğini kaydetmeden doğrula",
jmapSieveScriptCreate: "Yeni Sieve filtre betiklerini oluştur",
jmapSieveScriptUpdate: "Mevcut Sieve filtre betiklerini düzenle",
jmapSieveScriptDestroy: "Sieve filtre betiklerini kaldır",
jmapPrincipalGet: "Kullanıcıyı/grubu (principal) ayrıntılarını ve özelliklerini al",
jmapPrincipalQuery: "Kriterlerle eşleşen kullanıcıları/grupları (principal) ara",
jmapPrincipalChanges: "Belirli bir durumdan bu yana kullanıcıları/grupları (principal) üzerindeki değişiklikleri izle",
jmapPrincipalQueryChanges: "Kullanıcıyı/grubu (principal) sorgu sonuçlarındaki değişiklikleri izle",
jmapPrincipalGetAvailability: "Availability information for principals sorgula",
jmapPrincipalCreate: "Yeni kullanıcıları/grupları (principal) oluştur",
jmapPrincipalUpdate: "Mevcut kullanıcıları/grupları (principal) düzenle",
jmapPrincipalDestroy: "Kullanıcıları/grupları (principal) kaldır",
jmapQuotaGet: "Kota kullanımını ve sınırlarını al",
jmapQuotaChanges: "Belirli bir durumdan bu yana kotaları üzerindeki değişiklikleri izle",
jmapQuotaQuery: "Kriterlerle eşleşen kotaları ara",
jmapQuotaQueryChanges: "Kotayı sorgu sonuçlarındaki değişiklikleri izle",
jmapBlobGet: "Blob data indir",
jmapBlobCopy: "Blob nesnelerini başka bir hesaba kopyala",
jmapBlobLookup: "Veri türleri genelinde blob kullanımını sorgula",
jmapBlobUpload: "New blob data yükle",
jmapAddressBookGet: "Adres defterini ayrıntılarını ve özelliklerini al",
jmapAddressBookChanges: "Belirli bir durumdan bu yana adres defterlerini üzerindeki değişiklikleri izle",
jmapAddressBookCreate: "Yeni adres defterlerini oluştur",
jmapAddressBookUpdate: "Mevcut adres defterlerini düzenle",
jmapAddressBookDestroy: "Adres defterlerini kaldır",
jmapContactCardGet: "Kişi kartını ayrıntılarını al",
jmapContactCardChanges: "Belirli bir durumdan bu yana kişi kartlarını üzerindeki değişiklikleri izle",
jmapContactCardQuery: "Kriterlerle eşleşen kişi kartlarını ara",
jmapContactCardQueryChanges: "Kişi kartını sorgu sonuçlarındaki değişiklikleri izle",
jmapContactCardCreate: "Yeni kişi kartlarını oluştur",
jmapContactCardUpdate: "Mevcut kişi kartlarını düzenle",
jmapContactCardDestroy: "Kişi kartlarını kaldır",
jmapContactCardCopy: "Kişi kartlarını başka bir hesaba kopyala",
jmapContactCardParse: "Ham kişi kartı verilerini kaydetmeden ayrıştır",
jmapFileNodeGet: "Dosya düğümünü ayrıntılarını ve özelliklerini al",
jmapFileNodeChanges: "Belirli bir durumdan bu yana dosya düğümlerini üzerindeki değişiklikleri izle",
jmapFileNodeQuery: "Kriterlerle eşleşen dosya düğümlerini ara",
jmapFileNodeQueryChanges: "Dosya düğümünü sorgu sonuçlarındaki değişiklikleri izle",
jmapFileNodeCreate: "Yeni dosya düğümlerini oluştur",
jmapFileNodeUpdate: "Mevcut dosya düğümlerini düzenle",
jmapFileNodeDestroy: "Dosya düğümlerini kaldır",
jmapFileNodeCopy: "Dosya düğümlerini başka bir hesaba kopyala",
jmapShareNotificationGet: "Paylaşım bildirimini ayrıntılarını al",
jmapShareNotificationChanges: "Belirli bir durumdan bu yana paylaşım bildirimlerini üzerindeki değişiklikleri izle",
jmapShareNotificationQuery: "Kriterlerle eşleşen paylaşım bildirimlerini ara",
jmapShareNotificationQueryChanges: "Paylaşım bildirimini sorgu sonuçlarındaki değişiklikleri izle",
jmapShareNotificationCreate: "Yeni paylaşım bildirimlerini oluştur",
jmapShareNotificationUpdate: "Mevcut paylaşım bildirimlerini düzenle",
jmapShareNotificationDestroy: "Paylaşım bildirimlerini kaldır",
jmapCalendarGet: "Takvimi ayrıntılarını ve özelliklerini al",
jmapCalendarChanges: "Belirli bir durumdan bu yana takvimleri üzerindeki değişiklikleri izle",
jmapCalendarCreate: "Yeni takvimleri oluştur",
jmapCalendarUpdate: "Mevcut takvimleri düzenle",
jmapCalendarDestroy: "Takvimleri kaldır",
jmapCalendarEventGet: "Takvim etkinliğini ayrıntılarını al",
jmapCalendarEventChanges: "Belirli bir durumdan bu yana takvim etkinliklerini üzerindeki değişiklikleri izle",
jmapCalendarEventQuery: "Kriterlerle eşleşen takvim etkinliklerini ara",
jmapCalendarEventQueryChanges: "Takvim etkinliğini sorgu sonuçlarındaki değişiklikleri izle",
jmapCalendarEventCreate: "Yeni takvim etkinliklerini oluştur",
jmapCalendarEventUpdate: "Mevcut takvim etkinliklerini düzenle",
jmapCalendarEventDestroy: "Takvim etkinliklerini kaldır",
jmapCalendarEventCopy: "Takvim etkinliklerini başka bir hesaba kopyala",
jmapCalendarEventParse: "Ham takvim etkinliği verilerini kaydetmeden ayrıştır",
jmapCalendarEventNotificationGet: "Takvim etkinliği bildirimini ayrıntılarını al",
jmapCalendarEventNotificationChanges: "Belirli bir durumdan bu yana takvim etkinliği bildirimlerini üzerindeki değişiklikleri izle",
jmapCalendarEventNotificationQuery: "Kriterlerle eşleşen takvim etkinliği bildirimlerini ara",
jmapCalendarEventNotificationQueryChanges: "Takvim etkinliği bildirimini sorgu sonuçlarındaki değişiklikleri izle",
jmapCalendarEventNotificationCreate: "Yeni takvim etkinliği bildirimlerini oluştur",
jmapCalendarEventNotificationUpdate: "Mevcut takvim etkinliği bildirimlerini düzenle",
jmapCalendarEventNotificationDestroy: "Takvim etkinliği bildirimlerini kaldır",
jmapParticipantIdentityGet: "Participant identity ayrıntılarını al",
jmapParticipantIdentityChanges: "Belirli bir durumdan bu yana participant identities üzerindeki değişiklikleri izle",
jmapParticipantIdentityCreate: "Yeni participant identities oluştur",
jmapParticipantIdentityUpdate: "Mevcut participant identities düzenle",
jmapParticipantIdentityDestroy: "Participant identities kaldır",
jmapCoreEcho: "Test amacıyla giriş verisini geri yankıla (echo)",
imapAuthenticate: "IMAP sunucusuna giriş yap",
imapAclGet: "Posta kutusu erişim kontrol listelerini (ACL) görüntüle",
imapAclSet: "Posta kutusu erişim kontrol listelerini (ACL) düzenle",
imapMyRights: "Bir posta kutusundaki kendi yetkilerini görüntüle",
imapListRights: "Bir posta kutusu için kullanılabilir yetki bayraklarını listele",
imapAppend: "Messages to a mailbox yükle",
imapCapability: "Supported imap extensions sorgula",
imapId: "Server identification and version sorgula",
imapCopy: "Posta kutuları arasında iletileri kopyala",
imapMove: "Posta kutuları arasında iletileri taşı",
imapCreate: "Yeni posta kutularını oluştur",
imapDelete: "Mailboxes or mark messages for deletion sil",
imapEnable: "İsteğe bağlı IMAP uzantılarını etkinleştir",
imapExpunge: "Silinmek üzere işaretlenmiş iletileri kalıcı olarak kaldır (expunge)",
imapFetch: "Message content and metadata indir",
imapIdle: "Posta kutusu değişiklikleri için gerçek zamanlı bildirimler al (idle)",
imapList: "Kullanılabilir posta kutularını ve özniteliklerini listele",
imapLsub: "Kullanıcının abone olduğu posta kutularını listele",
imapNamespace: "Available mailbox namespace prefixes sorgula",
imapRename: "Posta kutularını yeniden adlandır veya taşı",
imapSearch: "Kriterlere göre iletileri ara",
imapSort: "İletileri belirli bir sıralama düzeninde al",
imapSelect: "Bir posta kutusunu okuma-yazma erişimi için aç",
imapExamine: "Bir posta kutusunu salt okunur erişim için aç",
imapStatus: "Mailbox message counts and state sorgula",
imapStore: "İleti bayraklarını ayarla veya temizle (okundu, yıldızlı, silindi vb.)",
imapSubscribe: "Posta kutularına abone ol veya abonelikten çık",
imapThread: "İletileri yazışma dizileri halinde grupla",
pop3Authenticate: "POP3 sunucusuna giriş yap",
pop3List: "İletileri ve boyutlarını listele",
pop3Uidl: "Benzersiz ileti tanımlayıcılarını al (UIDL)",
pop3Stat: "Mailbox message count and total size sorgula",
pop3Retr: "Message content indir",
pop3Dele: "Bağlantı kesildiğinde silinmek üzere iletileri işaretle",
sieveAuthenticate: "ManageSieve sunucusuna giriş yap",
sieveListScripts: "Yüklenen Sieve betiklerini listele",
sieveSetActive: "Hangi Sieve betiğinin aktif olduğunu belirle",
sieveGetScript: "Sieve script content indir",
sievePutScript: "Or replace a sieve script yükle",
sieveDeleteScript: "A sieve script kaldır",
sieveRenameScript: "Bir Sieve betiğini yeniden adlandır",
sieveCheckScript: "Sieve betiği sözdizimini kaydetmeden doğrula",
sieveHaveSpace: "Kotanın belirli boyuttaki bir betiği yüklemeye izin verip vermediğini denetle",
davSyncCollection: "Koleksiyon değişikliklerini istemciyle eşitle",
davExpandProperty: "Diğer kaynaklara başvuran özellikleri genişlet",
davPrincipalAcl: "Kullanıcılar/gruplar (principal) üzerindeki erişim kontrol özelliklerini yönet",
davPrincipalList: "Sistemdeki kullanılabilir kullanıcıları/grupları (principal) listele",
davPrincipalMatch: "Belirtilen kriterlere göre kullanıcıları/grupları (principal) eşleştir",
davPrincipalSearch: "Özellik değerlerine göre kullanıcıları/grupları (principal) ara",
davPrincipalSearchPropSet: "Which properties can be searched on principals sorgula",
davFilePropFind: "Dosya kaynaklarının özelliklerini al",
davFilePropPatch: "Dosya kaynaklarının özelliklerini düzenle",
davFileGet: "File resources indir",
davFileMkCol: "Yeni file collections or directories oluştur",
davFileDelete: "File resources kaldır",
davFilePut: "Or modify file resources yükle",
davFileCopy: "Dosya kaynaklarını yeni konumlara kopyala",
davFileMove: "Dosya kaynaklarını yeni konumlara taşı",
davFileLock: "Eşzamanlı değişiklikleri önlemek için dosya kaynaklarını kilitle",
davFileAcl: "Dosya kaynakları için erişim kontrol listelerini (ACL) yönet",
davCardPropFind: "Adres defteri kayıtlarının özelliklerini al",
davCardPropPatch: "Adres defteri kayıtlarının özelliklerini düzenle",
davCardGet: "Address book entries indir",
davCardMkCol: "Yeni address book collections oluştur",
davCardDelete: "Address book entries or collections kaldır",
davCardPut: "Or modify address book entries yükle",
davCardCopy: "Adres defteri kayıtlarını yeni konumlara kopyala",
davCardMove: "Adres defteri kayıtlarını yeni konumlara taşı",
davCardLock: "Eşzamanlı değişiklikleri önlemek için adres defteri kayıtlarını kilitle",
davCardAcl: "Adres defteri kayıtları için erişim kontrol listelerini (ACL) yönet",
davCardQuery: "Kriterlerle eşleşen address book entries ara",
davCardMultiGet: "Tek bir istekte birden çok adres defteri kaydı al",
davCalPropFind: "Takvim kayıtlarının özelliklerini al",
davCalPropPatch: "Takvim kayıtlarının özelliklerini düzenle",
davCalGet: "Calendar entries indir",
davCalMkCol: "Yeni calendar collections oluştur",
davCalDelete: "Calendar entries or collections kaldır",
davCalPut: "Or modify calendar entries yükle",
davCalCopy: "Takvim kayıtlarını yeni konumlara kopyala",
davCalMove: "Takvim kayıtlarını yeni konumlara taşı",
davCalLock: "Eşzamanlı değişiklikleri önlemek için takvim kayıtlarını kilitle",
davCalAcl: "Takvim kayıtları için erişim kontrol listelerini (ACL) yönet",
davCalQuery: "Kriterlerle eşleşen calendar entries ara",
davCalMultiGet: "Tek bir istekte birden çok takvim kaydı al",
davCalFreeBusyQuery: "Free/busy time information for scheduling sorgula",
oAuthClientRegistration: "Yeni OAuth istemci uygulamaları kaydet",
oAuthClientOverride: "OAuth istemci kayıt gereksinimlerini atla",
liveTracing: "Gerçek zamanlı sunucu etkinlik izlerini (trace) görüntüle",
liveMetrics: "Gerçek zamanlı sunucu performans metriklerini görüntüle",
liveDeliveryTest: "Teslimat yapılandırmasını doğrulamak için test e-postaları gönder",
scimAccess: "SCIM uç noktası üzerinden kullanıcı ve grup sağla (provision)",
sysAccountGet: "Hesapları getir",
sysAccountCreate: "Hesapları oluştur",
sysAccountUpdate: "Hesapları güncelle",
sysAccountDestroy: "Hesapları sil",
sysAccountQuery: "Hesapları sorgula",
sysAccountPasswordGet: "Account password settings getir",
sysAccountPasswordUpdate: "Account password settings güncelle",
sysAccountSettingsGet: "Account settings settings getir",
sysAccountSettingsUpdate: "Account settings settings güncelle",
sysAcmeProviderGet: "Sağlayıcıları getir",
sysAcmeProviderCreate: "Sağlayıcıları oluştur",
sysAcmeProviderUpdate: "Sağlayıcıları güncelle",
sysAcmeProviderDestroy: "Sağlayıcıları sil",
sysAcmeProviderQuery: "Sağlayıcıları sorgula",
actionReloadSettings: "Yeniden yükle: Sunucu ayarları",
actionReloadTlsCertificates: "Yeniden yükle: TLS sertifikaları",
actionReloadLookupStores: "Yeniden yükle: Arama (lookup) depoları",
actionReloadBlockedIps: "Yeniden yükle: Engellenen IP listesi",
actionUpdateApps: "Uygulama Yönetimi: Uygulamaları güncelle",
actionTroubleshootDmarc: "DMARC: Sorun giderme",
actionClassifySpam: "Spam Filtresi: Bir iletiyi sınıflandır",
actionInvalidateCaches: "Önbellek: Tüm önbellekleri geçersiz kıl",
actionInvalidateNegativeCaches: "Önbellek: Negatif önbellekleri geçersiz kıl",
actionPauseMtaQueue: "MTA: Kuyruk işlemeyi duraklat",
actionResumeMtaQueue: "MTA: Kuyruk işlemeyi sürdür",
sysActionGet: "Eylemleri getir",
sysActionCreate: "Eylemleri oluştur",
sysActionUpdate: "Eylemleri güncelle",
sysActionDestroy: "Eylemleri sil",
sysActionQuery: "Eylemleri sorgula",
sysAddressBookGet: "Address book settings getir",
sysAddressBookUpdate: "Address book settings güncelle",
sysAiModelGet: "Yapay zeka modellerini getir",
sysAiModelCreate: "Yapay zeka modellerini oluştur",
sysAiModelUpdate: "Yapay zeka modellerini güncelle",
sysAiModelDestroy: "Yapay zeka modellerini sil",
sysAiModelQuery: "Yapay zeka modellerini sorgula",
sysAlertGet: "Uyarıları getir",
sysAlertCreate: "Uyarıları oluştur",
sysAlertUpdate: "Uyarıları güncelle",
sysAlertDestroy: "Uyarıları sil",
sysAlertQuery: "Uyarıları sorgula",
sysAllowedIpGet: "Adresleri getir",
sysAllowedIpCreate: "Adresleri oluştur",
sysAllowedIpUpdate: "Adresleri güncelle",
sysAllowedIpDestroy: "Adresleri sil",
sysAllowedIpQuery: "Adresleri sorgula",
sysApiKeyGet: "Api anahtarlarını getir",
sysApiKeyCreate: "Api anahtarlarını oluştur",
sysApiKeyUpdate: "Api anahtarlarını güncelle",
sysApiKeyDestroy: "Api anahtarlarını sil",
sysApiKeyQuery: "Api anahtarlarını sorgula",
sysAppPasswordGet: "Uygulama şifrelerini getir",
sysAppPasswordCreate: "Uygulama şifrelerini oluştur",
sysAppPasswordUpdate: "Uygulama şifrelerini güncelle",
sysAppPasswordDestroy: "Uygulama şifrelerini sil",
sysAppPasswordQuery: "Uygulama şifrelerini sorgula",
sysApplicationGet: "Uygulamaları getir",
sysApplicationCreate: "Uygulamaları oluştur",
sysApplicationUpdate: "Uygulamaları güncelle",
sysApplicationDestroy: "Uygulamaları sil",
sysApplicationQuery: "Uygulamaları sorgula",
sysArchivedItemGet: "Arşivlenmiş ögeleri getir",
sysArchivedItemCreate: "Arşivlenmiş ögeleri oluştur",
sysArchivedItemUpdate: "Arşivlenmiş ögeleri güncelle",
sysArchivedItemDestroy: "Arşivlenmiş ögeleri sil",
sysArchivedItemQuery: "Arşivlenmiş ögeleri sorgula",
sysArfExternalReportGet: "Raporları getir",
sysArfExternalReportCreate: "Raporları oluştur",
sysArfExternalReportUpdate: "Raporları güncelle",
sysArfExternalReportDestroy: "Raporları sil",
sysArfExternalReportQuery: "Raporları sorgula",
sysAsnGet: "Asn settings getir",
sysAsnUpdate: "Asn settings güncelle",
sysAuthenticationGet: "Authentication settings getir",
sysAuthenticationUpdate: "Authentication settings güncelle",
sysBlobStoreGet: "Blob store settings getir",
sysBlobStoreUpdate: "Blob store settings güncelle",
sysBlockedIpGet: "Adresleri getir",
sysBlockedIpCreate: "Adresleri oluştur",
sysBlockedIpUpdate: "Adresleri güncelle",
sysBlockedIpDestroy: "Adresleri sil",
sysBlockedIpQuery: "Adresleri sorgula",
sysBootstrapGet: "Bootstrap settings getir",
sysBootstrapUpdate: "Bootstrap settings güncelle",
sysCacheGet: "Cache settings getir",
sysCacheUpdate: "Cache settings güncelle",
sysCalendarGet: "Calendar settings getir",
sysCalendarUpdate: "Calendar settings güncelle",
sysCalendarAlarmGet: "Calendar alarm settings getir",
sysCalendarAlarmUpdate: "Calendar alarm settings güncelle",
sysCalendarSchedulingGet: "Calendar scheduling settings getir",
sysCalendarSchedulingUpdate: "Calendar scheduling settings güncelle",
sysCertificateGet: "Sertifikaları getir",
sysCertificateCreate: "Sertifikaları oluştur",
sysCertificateUpdate: "Sertifikaları güncelle",
sysCertificateDestroy: "Sertifikaları sil",
sysCertificateQuery: "Sertifikaları sorgula",
sysClusterNodeGet: "Küme düğümlerini getir",
sysClusterNodeCreate: "Küme düğümlerini oluştur",
sysClusterNodeUpdate: "Küme düğümlerini güncelle",
sysClusterNodeDestroy: "Küme düğümlerini sil",
sysClusterNodeQuery: "Küme düğümlerini sorgula",
sysClusterRoleGet: "Rolleri getir",
sysClusterRoleCreate: "Rolleri oluştur",
sysClusterRoleUpdate: "Rolleri güncelle",
sysClusterRoleDestroy: "Rolleri sil",
sysClusterRoleQuery: "Rolleri sorgula",
sysCoordinatorGet: "Coordinator settings getir",
sysCoordinatorUpdate: "Coordinator settings güncelle",
sysDataRetentionGet: "Data retention settings getir",
sysDataRetentionUpdate: "Data retention settings güncelle",
sysDataStoreGet: "Data store settings getir",
sysDataStoreUpdate: "Data store settings güncelle",
sysDirectoryGet: "Dizinleri getir",
sysDirectoryCreate: "Dizinleri oluştur",
sysDirectoryUpdate: "Dizinleri güncelle",
sysDirectoryDestroy: "Dizinleri sil",
sysDirectoryQuery: "Dizinleri sorgula",
sysDkimReportSettingsGet: "Dkim report settings settings getir",
sysDkimReportSettingsUpdate: "Dkim report settings settings güncelle",
sysDkimSignatureGet: "Imzaları getir",
sysDkimSignatureCreate: "Imzaları oluştur",
sysDkimSignatureUpdate: "Imzaları güncelle",
sysDkimSignatureDestroy: "Imzaları sil",
sysDkimSignatureQuery: "Imzaları sorgula",
sysDmarcExternalReportGet: "Raporları getir",
sysDmarcExternalReportCreate: "Raporları oluştur",
sysDmarcExternalReportUpdate: "Raporları güncelle",
sysDmarcExternalReportDestroy: "Raporları sil",
sysDmarcExternalReportQuery: "Raporları sorgula",
sysDmarcInternalReportGet: "Raporları getir",
sysDmarcInternalReportCreate: "Raporları oluştur",
sysDmarcInternalReportUpdate: "Raporları güncelle",
sysDmarcInternalReportDestroy: "Raporları sil",
sysDmarcInternalReportQuery: "Raporları sorgula",
sysDmarcReportSettingsGet: "Dmarc report settings settings getir",
sysDmarcReportSettingsUpdate: "Dmarc report settings settings güncelle",
sysDnsResolverGet: "Dns resolver settings getir",
sysDnsResolverUpdate: "Dns resolver settings güncelle",
sysDnsServerGet: "Dns sunucularını getir",
sysDnsServerCreate: "Dns sunucularını oluştur",
sysDnsServerUpdate: "Dns sunucularını güncelle",
sysDnsServerDestroy: "Dns sunucularını sil",
sysDnsServerQuery: "Dns sunucularını sorgula",
sysDomainGet: "Alan adlarını getir",
sysDomainCreate: "Alan adlarını oluştur",
sysDomainUpdate: "Alan adlarını güncelle",
sysDomainDestroy: "Alan adlarını sil",
sysDomainQuery: "Alan adlarını sorgula",
sysDsnReportSettingsGet: "Dsn report settings settings getir",
sysDsnReportSettingsUpdate: "Dsn report settings settings güncelle",
sysEmailGet: "Email settings getir",
sysEmailUpdate: "Email settings güncelle",
sysEnterpriseGet: "Enterprise settings getir",
sysEnterpriseUpdate: "Enterprise settings güncelle",
sysEventTracingLevelGet: "Etkinlikleri getir",
sysEventTracingLevelCreate: "Etkinlikleri oluştur",
sysEventTracingLevelUpdate: "Etkinlikleri güncelle",
sysEventTracingLevelDestroy: "Etkinlikleri sil",
sysEventTracingLevelQuery: "Etkinlikleri sorgula",
sysFileStorageGet: "File storage settings getir",
sysFileStorageUpdate: "File storage settings güncelle",
sysHttpGet: "Http settings getir",
sysHttpUpdate: "Http settings güncelle",
sysHttpFormGet: "Http form settings getir",
sysHttpFormUpdate: "Http form settings güncelle",
sysHttpLookupGet: "Listeleri getir",
sysHttpLookupCreate: "Listeleri oluştur",
sysHttpLookupUpdate: "Listeleri güncelle",
sysHttpLookupDestroy: "Listeleri sil",
sysHttpLookupQuery: "Listeleri sorgula",
sysImapGet: "Imap settings getir",
sysImapUpdate: "Imap settings güncelle",
sysInMemoryStoreGet: "In memory store settings getir",
sysInMemoryStoreUpdate: "In memory store settings güncelle",
sysJmapGet: "Jmap settings getir",
sysJmapUpdate: "Jmap settings güncelle",
sysLogGet: "Günlük kayıtlarını getir",
sysLogCreate: "Günlük kayıtlarını oluştur",
sysLogUpdate: "Günlük kayıtlarını güncelle",
sysLogDestroy: "Günlük kayıtlarını sil",
sysLogQuery: "Günlük kayıtlarını sorgula",
sysMailingListGet: "E-posta listelerini getir",
sysMailingListCreate: "E-posta listelerini oluştur",
sysMailingListUpdate: "E-posta listelerini güncelle",
sysMailingListDestroy: "E-posta listelerini sil",
sysMailingListQuery: "E-posta listelerini sorgula",
sysMaskedEmailGet: "Maskelenmiş e-postaları getir",
sysMaskedEmailCreate: "Maskelenmiş e-postaları oluştur",
sysMaskedEmailUpdate: "Maskelenmiş e-postaları güncelle",
sysMaskedEmailDestroy: "Maskelenmiş e-postaları sil",
sysMaskedEmailQuery: "Maskelenmiş e-postaları sorgula",
sysMemoryLookupKeyGet: "Anahtarları getir",
sysMemoryLookupKeyCreate: "Anahtarları oluştur",
sysMemoryLookupKeyUpdate: "Anahtarları güncelle",
sysMemoryLookupKeyDestroy: "Anahtarları sil",
sysMemoryLookupKeyQuery: "Anahtarları sorgula",
sysMemoryLookupKeyValueGet: "Anahtarları getir",
sysMemoryLookupKeyValueCreate: "Anahtarları oluştur",
sysMemoryLookupKeyValueUpdate: "Anahtarları güncelle",
sysMemoryLookupKeyValueDestroy: "Anahtarları sil",
sysMemoryLookupKeyValueQuery: "Anahtarları sorgula",
sysMetricGet: "Metrikleri getir",
sysMetricCreate: "Metrikleri oluştur",
sysMetricUpdate: "Metrikleri güncelle",
sysMetricDestroy: "Metrikleri sil",
sysMetricQuery: "Metrikleri sorgula",
sysMetricsGet: "Metrics settings getir",
sysMetricsUpdate: "Metrics settings güncelle",
sysMetricsStoreGet: "Metrics store settings getir",
sysMetricsStoreUpdate: "Metrics store settings güncelle",
sysMtaConnectionStrategyGet: "Stratejileri getir",
sysMtaConnectionStrategyCreate: "Stratejileri oluştur",
sysMtaConnectionStrategyUpdate: "Stratejileri güncelle",
sysMtaConnectionStrategyDestroy: "Stratejileri sil",
sysMtaConnectionStrategyQuery: "Stratejileri sorgula",
sysMtaDeliveryScheduleGet: "Zamanlamaları getir",
sysMtaDeliveryScheduleCreate: "Zamanlamaları oluştur",
sysMtaDeliveryScheduleUpdate: "Zamanlamaları güncelle",
sysMtaDeliveryScheduleDestroy: "Zamanlamaları sil",
sysMtaDeliveryScheduleQuery: "Zamanlamaları sorgula",
sysMtaExtensionsGet: "Mta extensions settings getir",
sysMtaExtensionsUpdate: "Mta extensions settings güncelle",
sysMtaHookGet: "Kancaları (hook) getir",
sysMtaHookCreate: "Kancaları (hook) oluştur",
sysMtaHookUpdate: "Kancaları (hook) güncelle",
sysMtaHookDestroy: "Kancaları (hook) sil",
sysMtaHookQuery: "Kancaları (hook) sorgula",
sysMtaInboundSessionGet: "Mta inbound session settings getir",
sysMtaInboundSessionUpdate: "Mta inbound session settings güncelle",
sysMtaInboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
sysMtaInboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
sysMtaInboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
sysMtaInboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
sysMtaInboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
sysMtaMilterGet: "Milter'ları getir",
sysMtaMilterCreate: "Milter'ları oluştur",
sysMtaMilterUpdate: "Milter'ları güncelle",
sysMtaMilterDestroy: "Milter'ları sil",
sysMtaMilterQuery: "Milter'ları sorgula",
sysMtaOutboundStrategyGet: "Mta outbound strategy settings getir",
sysMtaOutboundStrategyUpdate: "Mta outbound strategy settings güncelle",
sysMtaOutboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
sysMtaOutboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
sysMtaOutboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
sysMtaOutboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
sysMtaOutboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
sysMtaQueueQuotaGet: "Kotaları getir",
sysMtaQueueQuotaCreate: "Kotaları oluştur",
sysMtaQueueQuotaUpdate: "Kotaları güncelle",
sysMtaQueueQuotaDestroy: "Kotaları sil",
sysMtaQueueQuotaQuery: "Kotaları sorgula",
sysMtaRouteGet: "Rotaları getir",
sysMtaRouteCreate: "Rotaları oluştur",
sysMtaRouteUpdate: "Rotaları güncelle",
sysMtaRouteDestroy: "Rotaları sil",
sysMtaRouteQuery: "Rotaları sorgula",
sysMtaStageAuthGet: "Mta stage auth settings getir",
sysMtaStageAuthUpdate: "Mta stage auth settings güncelle",
sysMtaStageConnectGet: "Mta stage connect settings getir",
sysMtaStageConnectUpdate: "Mta stage connect settings güncelle",
sysMtaStageDataGet: "Mta stage data settings getir",
sysMtaStageDataUpdate: "Mta stage data settings güncelle",
sysMtaStageEhloGet: "Mta stage ehlo settings getir",
sysMtaStageEhloUpdate: "Mta stage ehlo settings güncelle",
sysMtaStageMailGet: "Mta stage mail settings getir",
sysMtaStageMailUpdate: "Mta stage mail settings güncelle",
sysMtaStageRcptGet: "Mta stage rcpt settings getir",
sysMtaStageRcptUpdate: "Mta stage rcpt settings güncelle",
sysMtaStsGet: "Mta sts settings getir",
sysMtaStsUpdate: "Mta sts settings güncelle",
sysMtaTlsStrategyGet: "Stratejileri getir",
sysMtaTlsStrategyCreate: "Stratejileri oluştur",
sysMtaTlsStrategyUpdate: "Stratejileri güncelle",
sysMtaTlsStrategyDestroy: "Stratejileri sil",
sysMtaTlsStrategyQuery: "Stratejileri sorgula",
sysMtaVirtualQueueGet: "Kuyrukları getir",
sysMtaVirtualQueueCreate: "Kuyrukları oluştur",
sysMtaVirtualQueueUpdate: "Kuyrukları güncelle",
sysMtaVirtualQueueDestroy: "Kuyrukları sil",
sysMtaVirtualQueueQuery: "Kuyrukları sorgula",
sysNetworkListenerGet: "Dinleyicileri (listener) getir",
sysNetworkListenerCreate: "Dinleyicileri (listener) oluştur",
sysNetworkListenerUpdate: "Dinleyicileri (listener) güncelle",
sysNetworkListenerDestroy: "Dinleyicileri (listener) sil",
sysNetworkListenerQuery: "Dinleyicileri (listener) sorgula",
sysOAuthClientGet: "Oauth istemcilerini getir",
sysOAuthClientCreate: "Oauth istemcilerini oluştur",
sysOAuthClientUpdate: "Oauth istemcilerini güncelle",
sysOAuthClientDestroy: "Oauth istemcilerini sil",
sysOAuthClientQuery: "Oauth istemcilerini sorgula",
sysOidcProviderGet: "Oidc provider settings getir",
sysOidcProviderUpdate: "Oidc provider settings güncelle",
sysPublicKeyGet: "Genel anahtarları getir",
sysPublicKeyCreate: "Genel anahtarları oluştur",
sysPublicKeyUpdate: "Genel anahtarları güncelle",
sysPublicKeyDestroy: "Genel anahtarları sil",
sysPublicKeyQuery: "Genel anahtarları sorgula",
sysQueuedMessageGet: "Iletileri getir",
sysQueuedMessageCreate: "Iletileri oluştur",
sysQueuedMessageUpdate: "Iletileri güncelle",
sysQueuedMessageDestroy: "Iletileri sil",
sysQueuedMessageQuery: "Iletileri sorgula",
sysReportSettingsGet: "Report settings settings getir",
sysReportSettingsUpdate: "Report settings settings güncelle",
sysRoleGet: "Rolleri getir",
sysRoleCreate: "Rolleri oluştur",
sysRoleUpdate: "Rolleri güncelle",
sysRoleDestroy: "Rolleri sil",
sysRoleQuery: "Rolleri sorgula",
sysSearchGet: "Search settings getir",
sysSearchUpdate: "Search settings güncelle",
sysSearchStoreGet: "Search store settings getir",
sysSearchStoreUpdate: "Search store settings güncelle",
sysSecurityGet: "Security settings getir",
sysSecurityUpdate: "Security settings güncelle",
sysSenderAuthGet: "Sender auth settings getir",
sysSenderAuthUpdate: "Sender auth settings güncelle",
sysSharingGet: "Sharing settings getir",
sysSharingUpdate: "Sharing settings güncelle",
sysSieveSystemInterpreterGet: "Sieve system interpreter settings getir",
sysSieveSystemInterpreterUpdate: "Sieve system interpreter settings güncelle",
sysSieveSystemScriptGet: "Betikleri getir",
sysSieveSystemScriptCreate: "Betikleri oluştur",
sysSieveSystemScriptUpdate: "Betikleri güncelle",
sysSieveSystemScriptDestroy: "Betikleri sil",
sysSieveSystemScriptQuery: "Betikleri sorgula",
sysSieveUserInterpreterGet: "Sieve user interpreter settings getir",
sysSieveUserInterpreterUpdate: "Sieve user interpreter settings güncelle",
sysSieveUserScriptGet: "Betikleri getir",
sysSieveUserScriptCreate: "Betikleri oluştur",
sysSieveUserScriptUpdate: "Betikleri güncelle",
sysSieveUserScriptDestroy: "Betikleri sil",
sysSieveUserScriptQuery: "Betikleri sorgula",
sysSpamClassifierGet: "Spam classifier settings getir",
sysSpamClassifierUpdate: "Spam classifier settings güncelle",
sysSpamDnsblServerGet: "Sunucuları getir",
sysSpamDnsblServerCreate: "Sunucuları oluştur",
sysSpamDnsblServerUpdate: "Sunucuları güncelle",
sysSpamDnsblServerDestroy: "Sunucuları sil",
sysSpamDnsblServerQuery: "Sunucuları sorgula",
sysSpamDnsblSettingsGet: "Spam dnsbl settings settings getir",
sysSpamDnsblSettingsUpdate: "Spam dnsbl settings settings güncelle",
sysSpamFileExtensionGet: "Uzantıları getir",
sysSpamFileExtensionCreate: "Uzantıları oluştur",
sysSpamFileExtensionUpdate: "Uzantıları güncelle",
sysSpamFileExtensionDestroy: "Uzantıları sil",
sysSpamFileExtensionQuery: "Uzantıları sorgula",
sysSpamLlmGet: "Spam llm settings getir",
sysSpamLlmUpdate: "Spam llm settings güncelle",
sysSpamPyzorGet: "Spam pyzor settings getir",
sysSpamPyzorUpdate: "Spam pyzor settings güncelle",
sysSpamRuleGet: "Kuralları getir",
sysSpamRuleCreate: "Kuralları oluştur",
sysSpamRuleUpdate: "Kuralları güncelle",
sysSpamRuleDestroy: "Kuralları sil",
sysSpamRuleQuery: "Kuralları sorgula",
sysSpamSettingsGet: "Spam settings settings getir",
sysSpamSettingsUpdate: "Spam settings settings güncelle",
sysSpamTagGet: "Etiketleri getir",
sysSpamTagCreate: "Etiketleri oluştur",
sysSpamTagUpdate: "Etiketleri güncelle",
sysSpamTagDestroy: "Etiketleri sil",
sysSpamTagQuery: "Etiketleri sorgula",
sysSpamTrainingSampleGet: "Örnekleri getir",
sysSpamTrainingSampleCreate: "Örnekleri oluştur",
sysSpamTrainingSampleUpdate: "Örnekleri güncelle",
sysSpamTrainingSampleDestroy: "Örnekleri sil",
sysSpamTrainingSampleQuery: "Örnekleri sorgula",
sysSpfReportSettingsGet: "Spf report settings settings getir",
sysSpfReportSettingsUpdate: "Spf report settings settings güncelle",
sysStoreLookupGet: "Aramaları (lookup) getir",
sysStoreLookupCreate: "Aramaları (lookup) oluştur",
sysStoreLookupUpdate: "Aramaları (lookup) güncelle",
sysStoreLookupDestroy: "Aramaları (lookup) sil",
sysStoreLookupQuery: "Aramaları (lookup) sorgula",
sysSystemSettingsGet: "System settings settings getir",
sysSystemSettingsUpdate: "System settings settings güncelle",
taskIndexDocument: "Belgeyi dizine ekle",
taskUnindexDocument: "Belgeyi dizinden çıkar",
taskIndexTrace: "Telemetri izini (trace) dizine ekle",
taskCalendarAlarmEmail: "Takvim anımsatıcı e-postası",
taskCalendarAlarmNotification: "Takvim anımsatıcı bildirimi",
taskCalendarItipMessage: "Takvim iTIP iletisi",
taskMergeThreads: "E-posta yazışma dizilerini birleştir",
taskDmarcReport: "Uzak sunucuya DMARC raporu gönder",
taskTlsReport: "Uzak sunucuya TLS raporu gönder",
taskRestoreArchivedItem: "Arşivlenmiş ögeyi geri yükle",
taskDestroyAccount: "Account and all associated data sil",
taskAccountMaintenance: "account maintenance operations işlemini gerçekleştir",
taskTenantMaintenance: "tenant maintenance operations işlemini gerçekleştir",
taskStoreMaintenance: "store maintenance operations işlemini gerçekleştir",
taskSpamFilterMaintenance: "spam filter maintenance operations işlemini gerçekleştir",
taskAcmeRenewal: "ACME certificate renewal for a domain işlemini gerçekleştir",
taskDkimManagement: "DKIM key rotation for a domain işlemini gerçekleştir",
taskDnsManagement: "DNS management for a domain işlemini gerçekleştir",
sysTaskGet: "Görevleri getir",
sysTaskCreate: "Görevleri oluştur",
sysTaskUpdate: "Görevleri güncelle",
sysTaskDestroy: "Görevleri sil",
sysTaskQuery: "Görevleri sorgula",
sysTaskManagerGet: "Task manager settings getir",
sysTaskManagerUpdate: "Task manager settings güncelle",
sysTenantGet: "Kiracıları (tenant) getir",
sysTenantCreate: "Kiracıları (tenant) oluştur",
sysTenantUpdate: "Kiracıları (tenant) güncelle",
sysTenantDestroy: "Kiracıları (tenant) sil",
sysTenantQuery: "Kiracıları (tenant) sorgula",
sysTlsExternalReportGet: "Raporları getir",
sysTlsExternalReportCreate: "Raporları oluştur",
sysTlsExternalReportUpdate: "Raporları güncelle",
sysTlsExternalReportDestroy: "Raporları sil",
sysTlsExternalReportQuery: "Raporları sorgula",
sysTlsInternalReportGet: "Raporları getir",
sysTlsInternalReportCreate: "Raporları oluştur",
sysTlsInternalReportUpdate: "Raporları güncelle",
sysTlsInternalReportDestroy: "Raporları sil",
sysTlsInternalReportQuery: "Raporları sorgula",
sysTlsReportSettingsGet: "Tls report settings settings getir",
sysTlsReportSettingsUpdate: "Tls report settings settings güncelle",
sysTraceGet: "Izleme kayıtlarını (trace) getir",
sysTraceCreate: "Izleme kayıtlarını (trace) oluştur",
sysTraceUpdate: "Izleme kayıtlarını (trace) güncelle",
sysTraceDestroy: "Izleme kayıtlarını (trace) sil",
sysTraceQuery: "Izleme kayıtlarını (trace) sorgula",
sysTracerGet: "Izleyicileri (tracer) getir",
sysTracerCreate: "Izleyicileri (tracer) oluştur",
sysTracerUpdate: "Izleyicileri (tracer) güncelle",
sysTracerDestroy: "Izleyicileri (tracer) sil",
sysTracerQuery: "Izleyicileri (tracer) sorgula",
sysTracingStoreGet: "Tracing store settings getir",
sysTracingStoreUpdate: "Tracing store settings güncelle",
sysWebDavGet: "Web dav settings getir",
sysWebDavUpdate: "Web dav settings güncelle",
sysWebHookGet: "Webhook'ları getir",
sysWebHookCreate: "Webhook'ları oluştur",
sysWebHookUpdate: "Webhook'ları güncelle",
sysWebHookDestroy: "Webhook'ları sil",
sysWebHookQuery: "Webhook'ları sorgula",
},
};
+43 -4
View File
@@ -142,8 +142,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Os números que sua função pode ver, como o servidor os informa.", "The numbers your role can see, as the server reports them.": "Os números que sua função pode ver, como o servidor os informa.",
"Nothing to show": "Nada para mostrar", "Nothing to show": "Nada para mostrar",
"Could not be loaded": "Não foi possível carregar", "Could not be loaded": "Não foi possível carregar",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Métricas detalhadas, a fila de entrega, os registros e as configurações do servidor ficam no INBUXA Admin.", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Métricas detalhadas, a fila de entrega, os registros e as configurações do servidor ficam no inbuxa Admin.",
"Open INBUXA Admin": "Abrir o INBUXA Admin", "Open inbuxa Admin": "Abrir o inbuxa Admin",
"Default group role": "Função padrão de grupo", "Default group role": "Função padrão de grupo",
"A group needs an address.": "Um grupo precisa de um endereço.", "A group needs an address.": "Um grupo precisa de um endereço.",
"New group": "Novo grupo", "New group": "Novo grupo",
@@ -215,7 +215,7 @@ export const catalog: Catalog = {
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "O servidor de e-mail atribui esta função por padrão a {kinds}. Uma alteração aqui vale para todos que a têm dessa forma.", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "O servidor de e-mail atribui esta função por padrão a {kinds}. Uma alteração aqui vale para todos que a têm dessa forma.",
"Builds on": "Baseia-se em", "Builds on": "Baseia-se em",
"Permissions": "Permissões", "Permissions": "Permissões",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "O servidor de e-mail atribui esta função por padrão, então ela não pode ser excluída. Altere primeiro os padrões no INBUXA Admin.", "The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "O servidor de e-mail atribui esta função por padrão, então ela não pode ser excluída. Altere primeiro os padrões no inbuxa Admin.",
"This role carries permissions yours doesn't.": "Esta função tem permissões que a sua não tem.", "This role carries permissions yours doesn't.": "Esta função tem permissões que a sua não tem.",
"Create role": "Criar função", "Create role": "Criar função",
"builds on this one": "baseia-se nesta", "builds on this one": "baseia-se nesta",
@@ -811,6 +811,9 @@ export const catalog: Catalog = {
"Text size": "Tamanho do texto", "Text size": "Tamanho do texto",
"Font size": "Tamanho da fonte", "Font size": "Tamanho da fonte",
"Small": "Pequeno", "Small": "Pequeno",
"Original size": "Tamanho original",
"Drag to resize": "Arraste para redimensionar",
"Image size": "Tamanho da imagem",
"Medium": "Médio", "Medium": "Médio",
"Large": "Grande", "Large": "Grande",
"Huge": "Muito grande", "Huge": "Muito grande",
@@ -1124,7 +1127,6 @@ export const catalog: Catalog = {
"Select a conversation to read it here · Press {key} for shortcuts": "Selecione uma conversa para lê-la aqui · {key} para os atalhos", "Select a conversation to read it here · Press {key} for shortcuts": "Selecione uma conversa para lê-la aqui · {key} para os atalhos",
"Select a message to read it here · Press {key} for shortcuts": "Selecione uma mensagem para lê-la aqui · {key} para os atalhos", "Select a message to read it here · Press {key} for shortcuts": "Selecione uma mensagem para lê-la aqui · {key} para os atalhos",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Dica: pressione {key} em uma conversa para aplicar marcadores. Pesquise com {operator}.", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Dica: pressione {key} em uma conversa para aplicar marcadores. Pesquise com {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Um webmail livre, rápido e agradável para {server}, feito sobre JMAP.",
"Defaults for the calendar views and new events.": "Padrões das visualizações da agenda e dos eventos novos.", "Defaults for the calendar views and new events.": "Padrões das visualizações da agenda e dos eventos novos.",
"Replies will go to this address instead of the From address": "As respostas irão para este endereço em vez do endereço do remetente", "Replies will go to this address instead of the From address": "As respostas irão para este endereço em vez do endereço do remetente",
"Replies to mail sent from this identity go here instead of the From address.": "As respostas às mensagens enviadas por esta identidade chegam aqui em vez do endereço do remetente.", "Replies to mail sent from this identity go here instead of the From address.": "As respostas às mensagens enviadas por esta identidade chegam aqui em vez do endereço do remetente.",
@@ -1383,6 +1385,8 @@ export const catalog: Catalog = {
"Collapse all": "Recolher tudo", "Collapse all": "Recolher tudo",
"Expand all": "Expandir tudo", "Expand all": "Expandir tudo",
"Send now instead": "Enviar agora mesmo", "Send now instead": "Enviar agora mesmo",
"This message is rich text": "Esta mensagem está em texto formatado",
"This message is plain text": "Esta mensagem está em texto simples",
"Switch to plain text": "Mudar para texto simples", "Switch to plain text": "Mudar para texto simples",
"Switch to rich text": "Mudar para texto formatado", "Switch to rich text": "Mudar para texto formatado",
"{used} of {total} used": "{used} de {total} usados", "{used} of {total} used": "{used} de {total} usados",
@@ -1716,6 +1720,41 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Para confirmar, digite {phrase}", "To confirm, type {phrase}": "Para confirmar, digite {phrase}",
"Turn off legacy protocols": "Desativar os protocolos de e-mail legados", "Turn off legacy protocols": "Desativar os protocolos de e-mail legados",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Os protocolos de e-mail legados estão desativados para sua organização. Só {app} e aplicativos JMAP podem entrar.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Os protocolos de e-mail legados estão desativados para sua organização. Só {app} e aplicativos JMAP podem entrar.",
// ── About: inbuxa, the suite ────────────────────────────────────
"{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} é uma suíte de e-mail completa: um servidor de e-mail, o console que o administra e este webmail, cada um um programa próprio, instalados juntos e livres sob a AGPL.",
"The suite": "A suíte",
"Administration console": "Console de administração",
"Webmail": "Webmail",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Nó do webmail",
"Mail server node": "Nó do servidor de e-mail",
"{host} does not resolve": "{host} não resolve",
"unavailable": "indisponível",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Opinião do modelo de linguagem",
"One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Sua organização desativou {protocols} para aplicativos de e-mail. Os que usam isso não conseguem se conectar a esta conta; os outros ainda conseguem.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Alguns protocolos de e-mail legados estão desativados para sua organização: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Alguns estão desativados para {tenant}: {protocols}. Ative ou desative um de cada vez no console de administração.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Excluir, bloquear e retenções legais são feitos no console de administração, que registra o motivo e preserva o que uma retenção abrange.",
"Open in the console": "Abrir no console",
// inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "Você não tem mais acesso a {name}. De volta ao seu próprio e-mail.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Você não pode enviar de {name}. A conta foi entregue a você para leitura, não para enviar em nome dela.",
"This account was handed to you to read. Nothing in it can be changed.": "Esta conta foi entregue a você para leitura. Nada nela pode ser alterado.",
"You can file and move mail in this account, but not delete it.": "Você pode arquivar e mover e-mails nesta conta, mas não excluí-los.",
"Read only": "Somente leitura",
"Read and organize": "Ler e organizar",
"Full access": "Acesso total",
"Locked account:": "Conta bloqueada:",
"You can send as this account": "Você pode enviar como esta conta",
"Back to my mail": "Voltar ao meu e-mail",
"Send or close the message you're writing first.": "Envie ou feche primeiro a mensagem que está escrevendo.",
"Mail to show": "E-mail a exibir",
"My mail": "Meu e-mail",
"Locked account": "Conta bloqueada",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+43 -4
View File
@@ -141,8 +141,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.", "The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.",
"Nothing to show": "Нечего показать", "Nothing to show": "Нечего показать",
"Could not be loaded": "Не удалось загрузить", "Could not be loaded": "Не удалось загрузить",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в INBUXA Admin.", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в inbuxa Admin.",
"Open INBUXA Admin": "Открыть INBUXA Admin", "Open inbuxa Admin": "Открыть inbuxa Admin",
"Default group role": "Роль группы по умолчанию", "Default group role": "Роль группы по умолчанию",
"A group needs an address.": "Группе нужен адрес.", "A group needs an address.": "Группе нужен адрес.",
"New group": "Новая группа", "New group": "Новая группа",
@@ -214,7 +214,7 @@ export const catalog: Catalog = {
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Почтовый сервер по умолчанию выдаёт эту роль: {kinds}. Изменение здесь затронет всех, кто получил её так.", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Почтовый сервер по умолчанию выдаёт эту роль: {kinds}. Изменение здесь затронет всех, кто получил её так.",
"Builds on": "Основана на", "Builds on": "Основана на",
"Permissions": "Разрешения", "Permissions": "Разрешения",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Почтовый сервер выдаёт эту роль по умолчанию, поэтому её нельзя удалить. Сначала измените значения по умолчанию в INBUXA Admin.", "The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Почтовый сервер выдаёт эту роль по умолчанию, поэтому её нельзя удалить. Сначала измените значения по умолчанию в inbuxa Admin.",
"This role carries permissions yours doesn't.": "У этой роли есть разрешения, которых нет у вашей.", "This role carries permissions yours doesn't.": "У этой роли есть разрешения, которых нет у вашей.",
"Create role": "Создать роль", "Create role": "Создать роль",
"builds on this one": "основана на этой", "builds on this one": "основана на этой",
@@ -811,6 +811,9 @@ export const catalog: Catalog = {
"Text size": "Размер текста", "Text size": "Размер текста",
"Font size": "Размер шрифта", "Font size": "Размер шрифта",
"Small": "Мелкий", "Small": "Мелкий",
"Original size": "Исходный размер",
"Drag to resize": "Перетащите, чтобы изменить размер",
"Image size": "Размер изображения",
"Medium": "Средний", "Medium": "Средний",
"Large": "Крупный", "Large": "Крупный",
"Huge": "Очень крупный", "Huge": "Очень крупный",
@@ -1123,7 +1126,6 @@ export const catalog: Catalog = {
"Select a conversation to read it here · Press {key} for shortcuts": "Выберите цепочку, чтобы прочитать её здесь · {key} — сочетания клавиш", "Select a conversation to read it here · Press {key} for shortcuts": "Выберите цепочку, чтобы прочитать её здесь · {key} — сочетания клавиш",
"Select a message to read it here · Press {key} for shortcuts": "Выберите письмо, чтобы прочитать его здесь · {key} — сочетания клавиш", "Select a message to read it here · Press {key} for shortcuts": "Выберите письмо, чтобы прочитать его здесь · {key} — сочетания клавиш",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Совет: нажмите {key} на цепочке, чтобы присвоить ярлыки. Ищите через {operator}.", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Совет: нажмите {key} на цепочке, чтобы присвоить ярлыки. Ищите через {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Быстрая и удобная веб-почта с открытым кодом для {server}, построенная на JMAP.",
"Defaults for the calendar views and new events.": "Значения по умолчанию для видов календаря и новых событий.", "Defaults for the calendar views and new events.": "Значения по умолчанию для видов календаря и новых событий.",
"Replies will go to this address instead of the From address": "Ответы будут приходить на этот адрес, а не на адрес отправителя", "Replies will go to this address instead of the From address": "Ответы будут приходить на этот адрес, а не на адрес отправителя",
"Replies to mail sent from this identity go here instead of the From address.": "Ответы на письма из этого профиля приходят сюда, а не на адрес отправителя.", "Replies to mail sent from this identity go here instead of the From address.": "Ответы на письма из этого профиля приходят сюда, а не на адрес отправителя.",
@@ -1382,6 +1384,8 @@ export const catalog: Catalog = {
"Collapse all": "Свернуть все", "Collapse all": "Свернуть все",
"Expand all": "Развернуть все", "Expand all": "Развернуть все",
"Send now instead": "Отправить сейчас", "Send now instead": "Отправить сейчас",
"This message is rich text": "Это письмо в формате HTML",
"This message is plain text": "Это письмо в виде простого текста",
"Switch to plain text": "Переключиться на обычный текст", "Switch to plain text": "Переключиться на обычный текст",
"Switch to rich text": "Переключиться на форматированный текст", "Switch to rich text": "Переключиться на форматированный текст",
"{used} of {total} used": "Использовано {used} из {total}", "{used} of {total} used": "Использовано {used} из {total}",
@@ -1715,6 +1719,41 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Для подтверждения введите {phrase}", "To confirm, type {phrase}": "Для подтверждения введите {phrase}",
"Turn off legacy protocols": "Отключить устаревшие почтовые протоколы", "Turn off legacy protocols": "Отключить устаревшие почтовые протоколы",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.",
// ── About: inbuxa, the suite ────────────────────────────────────
"{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} — полноценный почтовый пакет: почтовый сервер, консоль для его администрирования и эта веб-почта. Каждый компонент — отдельная программа; устанавливаются они вместе и свободно распространяются по лицензии AGPL.",
"The suite": "Пакет",
"Administration console": "Консоль администрирования",
"Webmail": "Веб-почта",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Узел веб-почты",
"Mail server node": "Узел почтового сервера",
"{host} does not resolve": "{host} не разрешается",
"unavailable": "недоступно",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Мнение языковой модели",
"One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Ваша организация отключила {protocols} для почтовых приложений. Приложения, которые его используют, не могут подключиться к этому аккаунту; остальные могут.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Некоторые устаревшие почтовые протоколы отключены для вашей организации: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Для {tenant} отключены некоторые: {protocols}. Включайте и отключайте их по одному в консоли администрирования.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Удаление, блокировка и юридическое удержание выполняются в консоли администрирования: она записывает причину и сохраняет всё, что охватывает удержание.",
"Open in the console": "Открыть в консоли",
// inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "У вас больше нет доступа к {name}. Возвращаемся к вашей почте.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Вы не можете отправлять письма от имени {name}. Учётная запись передана вам для чтения, а не для отправки.",
"This account was handed to you to read. Nothing in it can be changed.": "Эта учётная запись передана вам для чтения. Изменить в ней ничего нельзя.",
"You can file and move mail in this account, but not delete it.": "В этой учётной записи вы можете раскладывать и перемещать письма, но не удалять их.",
"Read only": "Только чтение",
"Read and organize": "Чтение и упорядочивание",
"Full access": "Полный доступ",
"Locked account:": "Заблокированная учётная запись:",
"You can send as this account": "Вы можете отправлять от имени этой учётной записи",
"Back to my mail": "Вернуться к моей почте",
"Send or close the message you're writing first.": "Сначала отправьте или закройте письмо, которое пишете.",
"Mail to show": "Какую почту показать",
"My mail": "Моя почта",
"Locked account": "Заблокированная учётная запись",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
File diff suppressed because it is too large. Load diff
+43 -4
View File
@@ -135,8 +135,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.", "The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.",
"Nothing to show": "Нічого показати", "Nothing to show": "Нічого показати",
"Could not be loaded": "Не вдалося завантажити", "Could not be loaded": "Не вдалося завантажити",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в INBUXA Admin.", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в inbuxa Admin.",
"Open INBUXA Admin": "Відкрити INBUXA Admin", "Open inbuxa Admin": "Відкрити inbuxa Admin",
"Default group role": "Роль групи за замовчуванням", "Default group role": "Роль групи за замовчуванням",
"A group needs an address.": "Групі потрібна адреса.", "A group needs an address.": "Групі потрібна адреса.",
"New group": "Нова група", "New group": "Нова група",
@@ -208,7 +208,7 @@ export const catalog: Catalog = {
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Поштовий сервер типово надає цю роль: {kinds}. Зміна тут стосується всіх, хто отримав її так.", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Поштовий сервер типово надає цю роль: {kinds}. Зміна тут стосується всіх, хто отримав її так.",
"Builds on": "Базується на", "Builds on": "Базується на",
"Permissions": "Дозволи", "Permissions": "Дозволи",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Поштовий сервер типово надає цю роль, тому її не можна видалити. Спершу змініть типові значення в INBUXA Admin.", "The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Поштовий сервер типово надає цю роль, тому її не можна видалити. Спершу змініть типові значення в inbuxa Admin.",
"This role carries permissions yours doesn't.": "Ця роль має дозволи, яких немає у вашої.", "This role carries permissions yours doesn't.": "Ця роль має дозволи, яких немає у вашої.",
"Create role": "Створити роль", "Create role": "Створити роль",
"builds on this one": "базується на цій", "builds on this one": "базується на цій",
@@ -805,6 +805,9 @@ export const catalog: Catalog = {
"Text size": "Розмір тексту", "Text size": "Розмір тексту",
"Font size": "Розмір шрифту", "Font size": "Розмір шрифту",
"Small": "Дрібний", "Small": "Дрібний",
"Original size": "Початковий розмір",
"Drag to resize": "Перетягніть, щоб змінити розмір",
"Image size": "Розмір зображення",
"Medium": "Середній", "Medium": "Середній",
"Large": "Великий", "Large": "Великий",
"Huge": "Дуже великий", "Huge": "Дуже великий",
@@ -1117,7 +1120,6 @@ export const catalog: Catalog = {
"Select a conversation to read it here · Press {key} for shortcuts": "Виберіть листування, щоб прочитати його тут · {key} — сполучення клавіш", "Select a conversation to read it here · Press {key} for shortcuts": "Виберіть листування, щоб прочитати його тут · {key} — сполучення клавіш",
"Select a message to read it here · Press {key} for shortcuts": "Виберіть лист, щоб прочитати його тут · {key} — сполучення клавіш", "Select a message to read it here · Press {key} for shortcuts": "Виберіть лист, щоб прочитати його тут · {key} — сполучення клавіш",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Порада: натисніть {key} на листуванні, щоб додати мітки. Шукайте через {operator}.", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Порада: натисніть {key} на листуванні, щоб додати мітки. Шукайте через {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Швидка та зручна вебпошта з відкритим кодом для {server}, побудована на JMAP.",
"Defaults for the calendar views and new events.": "Значення за замовчуванням для виглядів календаря та нових подій.", "Defaults for the calendar views and new events.": "Значення за замовчуванням для виглядів календаря та нових подій.",
"Replies will go to this address instead of the From address": "Відповіді надходитимуть на цю адресу, а не на адресу відправника", "Replies will go to this address instead of the From address": "Відповіді надходитимуть на цю адресу, а не на адресу відправника",
"Replies to mail sent from this identity go here instead of the From address.": "Відповіді на листи з цього профілю надходять сюди, а не на адресу відправника.", "Replies to mail sent from this identity go here instead of the From address.": "Відповіді на листи з цього профілю надходять сюди, а не на адресу відправника.",
@@ -1376,6 +1378,8 @@ export const catalog: Catalog = {
"Collapse all": "Згорнути все", "Collapse all": "Згорнути все",
"Expand all": "Розгорнути все", "Expand all": "Розгорнути все",
"Send now instead": "Надіслати зараз", "Send now instead": "Надіслати зараз",
"This message is rich text": "Цей лист у форматі HTML",
"This message is plain text": "Цей лист у вигляді простого тексту",
"Switch to plain text": "Перейти на звичайний текст", "Switch to plain text": "Перейти на звичайний текст",
"Switch to rich text": "Перейти на форматований текст", "Switch to rich text": "Перейти на форматований текст",
"{used} of {total} used": "Використано {used} з {total}", "{used} of {total} used": "Використано {used} з {total}",
@@ -1709,6 +1713,41 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Для підтвердження введіть {phrase}", "To confirm, type {phrase}": "Для підтвердження введіть {phrase}",
"Turn off legacy protocols": "Вимкнути застарілі поштові протоколи", "Turn off legacy protocols": "Вимкнути застарілі поштові протоколи",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.",
// ── About: inbuxa, the suite ────────────────────────────────────
"{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} — повноцінний поштовий пакет: поштовий сервер, консоль для його адміністрування і ця вебпошта. Кожен компонент — окрема програма; встановлюються вони разом і вільно поширюються за ліцензією AGPL.",
"The suite": "Пакет",
"Administration console": "Консоль адміністрування",
"Webmail": "Вебпошта",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Вузол вебпошти",
"Mail server node": "Вузол поштового сервера",
"{host} does not resolve": "{host} не розпізнається",
"unavailable": "недоступно",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Думка мовної моделі",
"One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Ваша організація вимкнула {protocols} для поштових програм. Програми, які його використовують, не можуть підключитися до цього облікового запису; інші можуть.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Деякі застарілі поштові протоколи вимкнено для вашої організації: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Для {tenant} вимкнено деякі: {protocols}. Вмикайте й вимикайте їх по одному в консолі адміністрування.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Видалення, блокування та юридичне утримання виконуються в консолі адміністрування: вона записує причину й зберігає все, що охоплює утримання.",
"Open in the console": "Відкрити в консолі",
// inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "У вас більше немає доступу до {name}. Повертаємося до вашої пошти.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Ви не можете надсилати листи від імені {name}. Обліковий запис передано вам для читання, а не для надсилання.",
"This account was handed to you to read. Nothing in it can be changed.": "Цей обліковий запис передано вам для читання. Змінити в ньому нічого не можна.",
"You can file and move mail in this account, but not delete it.": "У цьому обліковому записі ви можете впорядковувати й переміщувати листи, але не видаляти їх.",
"Read only": "Лише читання",
"Read and organize": "Читання й упорядкування",
"Full access": "Повний доступ",
"Locked account:": "Заблокований обліковий запис:",
"You can send as this account": "Ви можете надсилати від імені цього облікового запису",
"Back to my mail": "Повернутися до моєї пошти",
"Send or close the message you're writing first.": "Спочатку надішліть або закрийте лист, який пишете.",
"Mail to show": "Яку пошту показати",
"My mail": "Моя пошта",
"Locked account": "Заблокований обліковий запис",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+43 -4
View File
@@ -137,8 +137,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。", "The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。",
"Nothing to show": "没有可显示的内容", "Nothing to show": "没有可显示的内容",
"Could not be loaded": "无法加载", "Could not be loaded": "无法加载",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "详细指标、投递队列、日志和服务器设置都在 INBUXA Admin 中。", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "详细指标、投递队列、日志和服务器设置都在 inbuxa Admin 中。",
"Open INBUXA Admin": "打开 INBUXA Admin", "Open inbuxa Admin": "打开 inbuxa Admin",
"Default group role": "默认群组角色", "Default group role": "默认群组角色",
"A group needs an address.": "群组需要一个地址。", "A group needs an address.": "群组需要一个地址。",
"New group": "新建群组", "New group": "新建群组",
@@ -210,7 +210,7 @@ export const catalog: Catalog = {
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "邮件服务器默认将此角色授予{kinds}。在此处的更改会影响所有以此方式拥有该角色的人。", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "邮件服务器默认将此角色授予{kinds}。在此处的更改会影响所有以此方式拥有该角色的人。",
"Builds on": "基于", "Builds on": "基于",
"Permissions": "权限", "Permissions": "权限",
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "邮件服务器默认授予此角色,因此无法删除。请先在 INBUXA Admin 中更改默认设置。", "The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "邮件服务器默认授予此角色,因此无法删除。请先在 inbuxa Admin 中更改默认设置。",
"This role carries permissions yours doesn't.": "此角色拥有您的角色所没有的权限。", "This role carries permissions yours doesn't.": "此角色拥有您的角色所没有的权限。",
"Create role": "创建角色", "Create role": "创建角色",
"builds on this one": "基于此角色", "builds on this one": "基于此角色",
@@ -807,6 +807,9 @@ export const catalog: Catalog = {
"Text size": "文字大小", "Text size": "文字大小",
"Font size": "字号", "Font size": "字号",
"Small": "小", "Small": "小",
"Original size": "原始大小",
"Drag to resize": "拖动以调整大小",
"Image size": "图片大小",
"Medium": "中", "Medium": "中",
"Large": "大", "Large": "大",
"Huge": "特大", "Huge": "特大",
@@ -1045,7 +1048,6 @@ export const catalog: Catalog = {
"Select a conversation to read it here · Press {key} for shortcuts": "选择一个会话即可在此阅读 · 按 {key} 查看快捷键", "Select a conversation to read it here · Press {key} for shortcuts": "选择一个会话即可在此阅读 · 按 {key} 查看快捷键",
"Select a message to read it here · Press {key} for shortcuts": "选择一封邮件即可在此阅读 · 按 {key} 查看快捷键", "Select a message to read it here · Press {key} for shortcuts": "选择一封邮件即可在此阅读 · 按 {key} 查看快捷键",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "提示:在会话上按 {key} 可添加标签。使用 {operator} 搜索。", "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "提示:在会话上按 {key} 可添加标签。使用 {operator} 搜索。",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "一款面向 {server} 的快速、友好的开源网页邮箱,基于 JMAP 构建。",
// ── Filters, vacation, capability notices ────────────────────────── // ── Filters, vacation, capability notices ──────────────────────────
"Thanks for your message. I'm away until … and will reply when I'm back.": "感谢您的来信。我将外出至……,回来后会尽快回复。", "Thanks for your message. I'm away until … and will reply when I'm back.": "感谢您的来信。我将外出至……,回来后会尽快回复。",
@@ -1387,6 +1389,8 @@ export const catalog: Catalog = {
"Collapse all": "全部折叠", "Collapse all": "全部折叠",
"Expand all": "全部展开", "Expand all": "全部展开",
"Send now instead": "改为立即发送", "Send now instead": "改为立即发送",
"This message is rich text": "这封邮件是富文本",
"This message is plain text": "这封邮件是纯文本",
"Switch to plain text": "切换为纯文本", "Switch to plain text": "切换为纯文本",
"Switch to rich text": "切换为富文本", "Switch to rich text": "切换为富文本",
"{used} of {total} used": "已使用 {used},共 {total}", "{used} of {total} used": "已使用 {used},共 {total}",
@@ -1720,6 +1724,41 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "请输入 {phrase} 以确认", "To confirm, type {phrase}": "请输入 {phrase} 以确认",
"Turn off legacy protocols": "关闭传统邮件协议", "Turn off legacy protocols": "关闭传统邮件协议",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。",
// ── About: inbuxa, the suite ────────────────────────────────────
"{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} 是一套完整的邮件套件:邮件服务器、管理它的控制台,以及这个网页邮箱。三者各自是独立的程序,一起安装,并以 AGPL 许可证自由使用。",
"The suite": "套件",
"Administration console": "管理控制台",
"Webmail": "网页邮箱",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "网页邮箱节点",
"Mail server node": "邮件服务器节点",
"{host} does not resolve": "无法解析 {host}",
"unavailable": "不可用",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "语言模型的判断",
"One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "您的组织已为邮件应用关闭 {protocols}。使用它的邮件应用无法连接到此账户;其他应用仍可连接。",
"Some legacy mail protocols are off for your organization: {protocols}.": "您的组织已关闭部分传统邮件协议:{protocols}。",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "{tenant} 已关闭部分协议:{protocols}。请在管理控制台中逐个开启或关闭。",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "删除、锁定和法律保留均在管理控制台中进行,控制台会记录原因,并保留保留范围内的内容。",
"Open in the console": "在控制台中打开",
// inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "您已无法访问 {name}。已返回您自己的邮件。",
"You can't send from {name}. It was handed to you to read, not to send as.": "您不能从 {name} 发送邮件。该账户交给您是为了阅读,而不是代其发送。",
"This account was handed to you to read. Nothing in it can be changed.": "该账户交给您用于阅读,其中的任何内容都不能更改。",
"You can file and move mail in this account, but not delete it.": "您可以在此账户中整理和移动邮件,但不能删除。",
"Read only": "只读",
"Read and organize": "阅读并整理",
"Full access": "完全访问",
"Locked account:": "已锁定的账户:",
"You can send as this account": "您可以以此账户的身份发送",
"Back to my mail": "返回我的邮件",
"Send or close the message you're writing first.": "请先发送或关闭您正在撰写的邮件。",
"Mail to show": "要显示的邮件",
"My mail": "我的邮件",
"Locked account": "已锁定的账户",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
// inbuxa: DLP on send: the override travels with the submission, and the
// server's refusals are told apart from other errors.
import { describe, expect, it } from "vitest";
import { buildSubmission, dlpRefusalOf } from "../compose";
const base = {
identityId: "i1",
fromEmail: "[email protected]",
emailRef: "#m",
rcpts: [{ email: "[email protected]" }],
sentId: "sent",
draftsId: "drafts",
scheduledId: null,
sendAt: null,
};
describe("DLP on send", () => {
it("sends the override reason only when there is one", () => {
expect(buildSubmission(base).create["inbuxa:dlpOverride"]).toBeUndefined();
expect(buildSubmission({ ...base, dlpOverride: " " }).create["inbuxa:dlpOverride"]).toBeUndefined();
expect(buildSubmission({ ...base, dlpOverride: " Client asked " }).create["inbuxa:dlpOverride"]).toEqual({ reason: "Client asked" });
});
it("recognizes the server's refusals", () => {
expect(dlpRefusalOf({ type: "inbuxa:dlpWarning", rules: [{ name: "Cards", notice: "Looks like a card." }] })).toEqual({
kind: "warning",
rules: [{ name: "Cards", notice: "Looks like a card." }],
});
expect(dlpRefusalOf({ type: "inbuxa:dlpBlocked" })).toEqual({ kind: "blocked", rules: [] });
expect(dlpRefusalOf({ type: "forbiddenToSend" })).toBeNull();
expect(dlpRefusalOf(undefined)).toBeNull();
});
});
@@ -18,7 +18,7 @@ function draft(over: Partial<Draft> = {}): Draft {
requestReceipt: false, priority: "normal", requestReceipt: false, priority: "normal",
showCc: false, showBcc: false, showReplyTo: false, showCc: false, showBcc: false, showReplyTo: false,
minimized: false, maximized: false, dirty: false, savedAt: null, minimized: false, maximized: false, dirty: false, savedAt: null,
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, sendAt: null, saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, quoteHtml: "", quoteText: "", formatOffer: null, sendAt: null,
...over, ...over,
}; };
} }
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import { composeBlocked, useCompose } from "@/store/compose";
import { delegatedAccounts, delegationOf, mayDestroy, mayWrite } from "@/lib/delegation";
/**
* inbuxa AL-7: a locked account handed to the reader shows in place of their
* own mail, and only mail follows it; the reader never loses their own
* account, and a delegation taken away takes them back.
*/
const delegated = (access: string, sendAs = false) => ({
name: "[email protected]",
isPersonal: false,
isReadOnly: access === "read",
accountCapabilities: {
[CAP.mail]: {},
[CAP.calendars]: {},
[CAP.contacts]: {},
[CAP.filenode]: {},
"urn:inbuxa:jmap": { delegation: { locked: true, access, sendAs, until: null } },
},
});
const sessionWith = (locked: Record<string, unknown> | null) =>
({
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]: {} },
},
shared: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.mail]: {} } },
...(locked ? { locked } : {}),
},
primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" },
state: "s",
}) as unknown as JmapSession;
let nextSession: JmapSession;
beforeEach(() => {
nextSession = sessionWith(delegated("read"));
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: [], notFound: [] }, id]);
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}),
);
const session = sessionWith(delegated("read"));
client.session = session;
useSession.setState({ status: "authenticated", session, accountId: "own", viewing: null, delegationEnded: null });
useCompose.setState({ drafts: [] });
});
afterEach(() => {
useSession.setState({ viewing: null });
vi.unstubAllGlobals();
});
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, "shared")).toBeNull();
expect(delegationOf(session, "own")).toBeNull();
expect(delegatedAccounts(session).map((a) => a.id)).toEqual(["locked"]);
});
it("never lets a read delegate send, whatever the server says", () => {
const session = sessionWith(delegated("read", true));
expect(delegationOf(session, "locked")?.sendAs).toBe(false);
});
it("says what each level may do", () => {
const read = delegationOf(sessionWith(delegated("read")), "locked");
const organize = delegationOf(sessionWith(delegated("organize")), "locked");
expect(mayWrite(read)).toBe(false);
expect(mayWrite(organize)).toBe(true);
expect(mayDestroy(organize)).toBe(false);
expect(mayWrite(null) && mayDestroy(null)).toBe(true);
});
});
describe("viewing a locked account", () => {
it("moves only the mail store; the session's own account stays", () => {
useSession.getState().view("locked");
expect(useMail.getState().accountId).toBe("locked");
expect(useSession.getState().accountId).toBe("own");
expect(useSession.getState().ownAccountFor(CAP.mail)).toBe("own");
useSession.getState().view(null);
expect(useMail.getState().accountId).toBe("own");
});
it("brings the whole account: calendar, contacts and files, never the reader's settings", () => {
const s = () => useSession.getState();
expect(s().viewAccountFor(CAP.calendars)).toBe("own");
s().view("locked");
for (const cap of [CAP.calendars, CAP.contacts, CAP.filenode]) {
expect(s().viewAccountFor(cap)).toBe("locked");
}
// Settings live in the reader's own Files, whatever is in view
expect(s().ownAccountFor(CAP.filenode)).toBe("own");
s().view(null);
expect(s().viewAccountFor(CAP.contacts)).toBe("own");
});
it("refuses an account that isn't delegated", () => {
useSession.getState().view("shared");
expect(useSession.getState().viewing).toBeNull();
expect(useMail.getState().accountId).toBe("own");
});
it("goes back to the reader's own mail when the delegation ends", async () => {
useSession.getState().view("locked");
nextSession = sessionWith(null);
await useSession.getState().refresh();
expect(useSession.getState().viewing).toBeNull();
expect(useSession.getState().delegationEnded).toBe("[email protected]");
expect(useMail.getState().accountId).toBe("own");
});
it("blocks writing mail where the delegate can't send", () => {
expect(composeBlocked()).toBeNull();
useSession.getState().view("locked");
expect(composeBlocked()).toMatch(/[email protected]/);
expect(useCompose.getState().open()).toBe("");
expect(useCompose.getState().drafts).toHaveLength(0);
});
it("lets a send-as delegate write", () => {
const session = sessionWith(delegated("full", true));
client.session = session;
useSession.setState({ session });
useSession.getState().view("locked");
expect(composeBlocked()).toBeNull();
});
});
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it } from "vitest";
import { buildEmailObject, useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import { useContacts } from "@/store/contacts";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import type { Email, EmailAddress, Identity } from "@/jmap/types";
/*
* Remote images in a quoted message (#410).
*
* Quoting renders the message a second time. The reply was fetching every
* remote image in it, whatever the reader had decided — so replying to a
* message whose images had been left blocked told the tracker the mail was
* read and the address live. The composer is a window like any other.
*/
const PIXEL = "https://tracker.example/open.gif?id=42";
const MESSAGE = {
id: "m1", messageId: ["<[email protected]>"], subject: "Sale", references: [], inReplyTo: [], keywords: {},
attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
from: [{ name: "Shop", email: "[email protected]" }], to: [{ name: "John", email: "[email protected]" }], cc: [],
htmlBody: [{ partId: "2", type: "text/html" }],
textBody: [{ partId: "1", type: "text/plain" }],
bodyValues: {
"1": { value: "Sale on now", isEncodingProblem: false, isTruncated: false },
"2": { value: `<p>Sale on now</p><img src="${PIXEL}" width="1" height="1">`, isEncodingProblem: false, isTruncated: false },
},
} as unknown as Email;
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
/**
* Whether the draft will actually load the image. Allowed images go through
* the server's proxy where the deployment has one (#412), so the address is
* escaped inside an `/api/image` URL rather than sitting in `src` as it is.
*/
const fetched = (html: string) => html.includes(`/api/image?url=${encodeURIComponent(PIXEL)}`) || html.includes(`src="${PIXEL}"`);
function replyDraft() {
useMail.setState({
accountId: "a1",
identities: IDENTITIES as never,
getEmails: (async () => [MESSAGE]) as never,
defaultIdentity: (() => IDENTITIES[0]) as never,
loadIdentities: (async () => IDENTITIES) as never,
roleId: (() => null) as never,
});
return useCompose.getState().reply(MESSAGE, "reply").then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
}
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({ imagesShown: {} });
useContacts.setState({ loaded: false } as never);
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, imagePolicy: "ask", composeFormat: "html" } });
});
describe("quoting a message whose images were not allowed", () => {
it("does not put a fetchable address in the draft", async () => {
const d = await replyDraft();
expect(d.html).not.toContain(PIXEL.split("?")[0]! + '"');
expect(d.html).toContain("data-ihm-blocked");
// The src is what the browser would fetch; nothing else in the draft is.
expect(/<img[^>]+src="https:/.test(d.html)).toBe(false);
});
it("keeps the address, so the sent copy is the quote as it was written", async () => {
const d = await replyDraft();
expect(d.html).toContain(PIXEL);
const email = await buildEmailObject({ ...d, to: [{ name: null, email: "[email protected]" }] as EmailAddress[] }, { forSend: true });
const sent = JSON.stringify(email);
expect(sent).toContain(PIXEL);
expect(sent).not.toContain("data-ihm-blocked");
});
it("fetches them once the reader has shown images on that message", async () => {
useMail.setState({ imagesShown: { m1: true } });
const d = await replyDraft();
expect(fetched(d.html)).toBe(true);
expect(d.html).not.toContain("data-ihm-blocked");
});
it("fetches them when the policy is to show images always", async () => {
useSettings.setState((s) => ({ settings: { ...s.settings, imagePolicy: "always" } }));
expect(fetched((await replyDraft()).html)).toBe(true);
});
it("fetches them from a sender the reader trusts", async () => {
useSettings.setState((s) => ({ settings: { ...s.settings, trustedImageSenders: ["[email protected]"] } }));
expect(fetched((await replyDraft()).html)).toBe(true);
});
it("leaves them blocked for a stranger when the policy is contacts only", async () => {
useSettings.setState((s) => ({ settings: { ...s.settings, imagePolicy: "contacts" } }));
expect((await replyDraft()).html).toContain("data-ihm-blocked");
});
});
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it } from "vitest";
import { buildEmailObject, useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import { useContacts } from "@/store/contacts";
import { useSession } from "@/store/session";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import { unproxyImages } from "@/lib/mail/remoteImages";
import type { Email, EmailAddress, Identity } from "@/jmap/types";
/*
* Remote images in a quote go through this server, and come back out pointing
* at their own addresses (#412).
*
* Reading a message proxies its images so the sender learns nothing about the
* reader. Quoting fetched them directly, which handed the same pixel the
* reader's IP and user agent. Proxying the quote is only half of it: those
* URLs belong to this deployment, so the copy that is sent has to carry the
* originals or the recipient gets images only this server can serve.
*/
const IMAGE = "https://cdn.example/banner.png?id=7";
const MESSAGE = {
id: "m1", messageId: ["<[email protected]>"], subject: "Sale", references: [], inReplyTo: [], keywords: {},
attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
from: [{ name: "Shop", email: "[email protected]" }], to: [{ name: "John", email: "[email protected]" }], cc: [],
htmlBody: [{ partId: "2", type: "text/html" }],
textBody: [{ partId: "1", type: "text/plain" }],
bodyValues: {
"1": { value: "Sale on now", isEncodingProblem: false, isTruncated: false },
"2": { value: `<p>Sale</p><img src="${IMAGE}">`, isEncodingProblem: false, isTruncated: false },
},
} as unknown as Email;
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
function draftFor(mode: "reply" | "forward") {
useMail.setState({
accountId: "a1",
identities: IDENTITIES as never,
getEmails: (async () => [MESSAGE]) as never,
defaultIdentity: (() => IDENTITIES[0]) as never,
loadIdentities: (async () => IDENTITIES) as never,
roleId: (() => null) as never,
});
return useCompose.getState().reply(MESSAGE, mode).then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
}
const proxy = (on: boolean) => useSession.setState({ session: { ihasmail: { imageProxy: on } } } as never);
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({ imagesShown: {} });
useContacts.setState({ loaded: false } as never);
// Images allowed, so the question is only how they are fetched.
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, imagePolicy: "always", composeFormat: "html" } });
proxy(true);
});
describe("images in a quote, while the reply is being written", () => {
it("are fetched through this server, as reading the message does", async () => {
const d = await draftFor("reply");
expect(d.html).toContain("/api/image?url=");
expect(d.html).not.toContain(`src="${IMAGE}"`);
});
it("are fetched directly where the deployment has no proxy", async () => {
proxy(false);
const d = await draftFor("reply");
expect(d.html).toContain(`src="${IMAGE}"`);
expect(d.html).not.toContain("/api/image?url=");
});
it("go through it on a forward too", async () => {
expect((await draftFor("forward")).html).toContain("/api/image?url=");
});
});
describe("the copy that is sent", () => {
it("points at the image's own address, not at this server", async () => {
const d = await draftFor("reply");
const sent = JSON.stringify(await buildEmailObject({ ...d, to: [{ name: null, email: "[email protected]" }] as EmailAddress[] }, { forSend: true }));
expect(sent).toContain(IMAGE.replace(/&/g, "&"));
expect(sent).not.toContain("/api/image?url=");
});
it("restores a signature or template image that used the proxy as well", () => {
const logo = "https://cdn.example/logo.png";
const html = `<p>Regards</p><img src="/api/image?url=${encodeURIComponent(logo)}"><img src="cid:x@1">`;
const out = unproxyImages(html);
expect(out).toContain(`src="${logo}"`);
expect(out).toContain('src="cid:x@1"');
});
it("leaves everything else alone", () => {
const html = '<img src="cid:logo@1"><img src="blob:http://localhost/abc"><a href="/api/image?url=x">link</a>';
expect(unproxyImages(html)).toBe(html);
});
});
@@ -153,6 +153,30 @@ describe("a message of mine with nobody obvious to reply to", () => {
const d = await draftFor({ ...MINE, to: [ME], cc: [] } as Email, "reply"); const d = await draftFor({ ...MINE, to: [ME], cc: [] } as Email, "reply");
expect(addrs(d.to)).toEqual([ME.email]); expect(addrs(d.to)).toEqual([ME.email]);
}); });
it("answers the Reply-To rather than my own desk when nobody else is on it", async () => {
/*
* A contact form: the site mails itself, From and To both its own address,
* and the person who filled the form in is in Reply-To. From alone makes
* this look like mine, and the fallback used to reply to me (#415).
*/
const form = { ...MINE, to: [ME], cc: [], replyTo: [{ name: "Michael", email: "[email protected]" }] } as Email;
const d = await draftFor(form, "reply");
expect(addrs(d.to)).toEqual(["[email protected]"]);
});
it("does the same on a reply all, without cc-ing myself", async () => {
const form = { ...MINE, to: [ME], cc: [], replyTo: [{ name: "Michael", email: "[email protected]" }] } as Email;
const d = await draftFor(form, "replyAll");
expect(addrs(d.to)).toEqual(["[email protected]"]);
expect(d.cc).toEqual([]);
});
it("still prefers somebody I actually wrote to over my own Reply-To", async () => {
// The Cc is a person; the Reply-To is where answers to me belong.
const d = await draftFor({ ...MINE, to: [ME], replyTo: [{ name: null, email: "[email protected]" }] } as Email, "reply");
expect(addrs(d.to)).toEqual([BOB.email]);
});
}); });
describe("forwarding", () => { describe("forwarding", () => {
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import type { Email, Identity } from "@/jmap/types";
/*
* Offering to answer a message in the format it was written in (#407).
*
* The trap is `htmlBody`: RFC 8621 derives it, so a plain-text message has one
* too, holding its text/plain part. Reading that as "there is HTML" would
* offer a switch to rich text on every plain-text message, and never offer the
* switch to plain text where it is actually wanted.
*/
const base = {
messageId: ["<[email protected]>"], subject: "Numbers", references: [], inReplyTo: [],
keywords: {}, attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
from: [{ name: "Ann", email: "[email protected]" }], to: [{ name: "John", email: "[email protected]" }], cc: [],
};
/** A real multipart/alternative: two parts, one of them text/html. */
const RICH = {
...base, id: "m1",
htmlBody: [{ partId: "2", type: "text/html" }],
textBody: [{ partId: "1", type: "text/plain" }],
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false }, "2": { value: "<p>hi</p>", isEncodingProblem: false, isTruncated: false } },
} as unknown as Email;
/** Plain text, as Stalwart returns it: both lists name the same text/plain part. */
const PLAIN = {
...base, id: "m2",
htmlBody: [{ partId: "1", type: "text/plain" }],
textBody: [{ partId: "1", type: "text/plain" }],
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false } },
} as unknown as Email;
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
function draftFor(email: Email, mode: "reply" | "replyAll" | "forward") {
useMail.setState({
accountId: "a1",
identities: IDENTITIES as never,
getEmails: (async () => [email]) as never,
defaultIdentity: (() => IDENTITIES[0]) as never,
loadIdentities: (async () => IDENTITIES) as never,
roleId: (() => null) as never,
});
return useCompose.getState().reply(email, mode).then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
}
const composeIn = (format: "html" | "text") => useSettings.setState({ settings: { ...DEFAULT_SETTINGS, composeFormat: format } });
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null });
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
});
describe("answering a message written in the other format", () => {
it("offers rich text when a plain-text reply answers a rich message", async () => {
composeIn("text");
const d = await draftFor(RICH, "reply");
expect(d.format).toBe("text");
expect(d.formatOffer).toBe("html");
});
it("offers plain text when a rich reply answers a plain-text message", async () => {
composeIn("html");
const d = await draftFor(PLAIN, "reply");
expect(d.format).toBe("html");
expect(d.formatOffer).toBe("text");
});
it("offers nothing when the formats already agree", async () => {
composeIn("html");
expect((await draftFor(RICH, "reply")).formatOffer).toBeNull();
composeIn("text");
expect((await draftFor(PLAIN, "reply")).formatOffer).toBeNull();
});
it("reads the part's own type, not the derived htmlBody list", async () => {
// PLAIN has an htmlBody; it names the text/plain part. Offering a switch
// to rich text here would fire on every plain-text message there is.
composeIn("text");
expect((await draftFor(PLAIN, "reply")).formatOffer).toBeNull();
});
it("offers on a reply all and on a forward, where the same formatting is lost", async () => {
composeIn("text");
expect((await draftFor(RICH, "replyAll")).formatOffer).toBe("html");
expect((await draftFor(RICH, "forward")).formatOffer).toBe("html");
});
it("carries both bodies either way, so switching has something to switch to", async () => {
composeIn("text");
const d = await draftFor(RICH, "reply");
expect(d.text).toContain("hi");
expect(d.html).toContain("hi");
});
it("keeps the quoted message in both formats, so a switch can restore it", async () => {
composeIn("text");
const d = await draftFor(RICH, "reply");
// The HTML quote is the original's markup, not the text one converted.
expect(d.quoteHtml).toContain("<p>hi</p>");
expect(d.quoteHtml).toContain("ihm-quote");
expect(d.quoteText).toContain("Ann");
expect(d.text.endsWith(d.quoteText)).toBe(true);
});
it("quotes nothing on a message started from scratch", () => {
composeIn("text");
const key = useCompose.getState().open();
const d = useCompose.getState().drafts.find((x) => x.key === key)!;
expect(d.quoteHtml).toBe("");
expect(d.quoteText).toBe("");
});
it("makes no offer on a message started from scratch", () => {
composeIn("text");
const key = useCompose.getState().open();
expect(useCompose.getState().drafts.find((x) => x.key === key)!.formatOffer).toBeNull();
});
});
+3 -2
View File
@@ -404,7 +404,8 @@ export const useCalendar = create<CalendarState>((set, get) => ({
async init() { async init() {
// The reader's own: a shared calendar is shown beside theirs, not instead. // The reader's own: a shared calendar is shown beside theirs, not instead.
const accountId = useSession.getState().ownAccountFor(CAP.calendars); // inbuxa AL-7: or the locked account in view, the whole of it
const accountId = useSession.getState().viewAccountFor(CAP.calendars);
const available = Boolean(accountId && client.hasCapability(CAP.calendars)); const available = Boolean(accountId && client.hasCapability(CAP.calendars));
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} }); if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
set({ available }); set({ available });
@@ -431,7 +432,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
*/ */
async loadSharedCalendars() { async loadSharedCalendars() {
const session = useSession.getState(); const session = useSession.getState();
const own = session.ownAccountFor(CAP.calendars); const own = session.viewAccountFor(CAP.calendars);
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own); const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
// Every account at once, in one request, and listed in the session's order. // Every account at once, in one request, and listed in the session's order.
const answers = await Promise.all( const answers = await Promise.all(
+173 -12
View File
@@ -4,7 +4,8 @@ import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } fr
import { formatFullDate, uid } from "@/lib/format"; import { formatFullDate, uid } from "@/lib/format";
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address"; import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text/text"; import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text/text";
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html"; import { hasHtmlAlternative, sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
import { remoteImagesAllowed, restoreBlockedImages, unproxyImages } from "@/lib/mail/remoteImages";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail"; import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -13,9 +14,11 @@ import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n"; import { t as translate } from "@/lib/i18n";
import { BASE_PATH } from "@/lib/basePath"; import { BASE_PATH } from "@/lib/basePath";
import { settings } from "./settings"; import { settings } from "./settings";
import { useContacts } from "./contacts";
import { emlFilename } from "@/lib/text/emlName"; import { emlFilename } from "@/lib/text/emlName";
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders"; import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
import { shareBody, type SharedContent } from "@/lib/shareTarget"; import { shareBody, type SharedContent } from "@/lib/shareTarget";
import { delegationOf } from "@/lib/delegation";
export interface ComposeAttachment { export interface ComposeAttachment {
id: string; id: string;
@@ -75,9 +78,54 @@ export interface Draft {
/** Original identity signature HTML currently embedded, to replace on identity switch. */ /** Original identity signature HTML currently embedded, to replace on identity switch. */
signatureHtml: string; signatureHtml: string;
replyMode: "reply" | "replyAll" | "forward" | null; replyMode: "reply" | "replyAll" | "forward" | null;
/**
* The quoted message as it was prepared in each format, kept so that
* switching format re-attaches the original rather than a conversion of
* whatever the other format flattened it into. Empty on a draft that quotes
* nothing.
*/
quoteHtml: string;
quoteText: string;
/**
* The format the message being answered was written in, when it is not the
* one this draft opened in (#407). The composer offers the switch; answering
* it either way, or dismissing it, clears this.
*/
formatOffer: "html" | "text" | null;
mailboxIdOnSend?: Id | null; mailboxIdOnSend?: Id | null;
/** When set, hand the message to the server held until this instant. */ /** When set, hand the message to the server held until this instant. */
sendAt: number | null; sendAt: number | null;
/**
* The server's DLP rules refused the last send (inbuxa): a warning the
* sender may answer with a reason, or a block. The composer shows it.
*/
dlp?: DlpRefusalInfo | null;
/** The reason to send despite a DLP warning, for the next send only. */
dlpOverride?: string | null;
}
export interface DlpRefusalInfo {
kind: "warning" | "blocked";
rules: { name: string; notice: string }[];
}
/**
* A send the server's DLP rules refused (inbuxa: `inbuxa:dlpWarning`,
* `inbuxa:dlpBlocked`): which rules, and what they say.
*/
export class DlpRefusal extends Error {
constructor(public info: DlpRefusalInfo) {
super(info.rules.map((r) => r.notice).join(" ") || translate("This message wasn't sent under the server's rules"));
}
}
/** The DLP refusal in a submission's set error, if it is one. */
export function dlpRefusalOf(err: { type?: string; rules?: { name?: string; notice?: string }[] } | undefined): DlpRefusalInfo | null {
if (!err || (err.type !== "inbuxa:dlpWarning" && err.type !== "inbuxa:dlpBlocked")) return null;
return {
kind: err.type === "inbuxa:dlpWarning" ? "warning" : "blocked",
rules: (err.rules ?? []).map((r) => ({ name: r.name ?? "", notice: r.notice ?? "" })),
};
} }
interface ComposeState { interface ComposeState {
@@ -145,11 +193,39 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
error: null, error: null,
signatureHtml: "", signatureHtml: "",
replyMode: null, replyMode: null,
quoteHtml: "",
quoteText: "",
formatOffer: null,
sendAt: null, sendAt: null,
...init, ...init,
}; };
} }
/**
* Whether this message's remote images may be fetched into a composer.
*
* The same question the reader answered, asked with the same inputs: the
* policy, the trusted senders, whether the sender is a contact, and whether
* the reader pressed "Show images" on this message.
*/
/** Whether this deployment fetches remote images through its own server. */
function imageProxyOn(): boolean {
return useSession.getState().session?.ihasmail?.imageProxy ?? true;
}
function remoteImagesForMessage(email: Email): boolean {
const s = settings();
const from = email.from?.[0]?.email;
const contacts = useContacts.getState();
return remoteImagesAllowed({
from,
policy: s.imagePolicy,
trusted: s.trustedImageSenders,
inContacts: Boolean(from && contacts.loaded && contacts.lookupByEmail(from)),
shown: Boolean(useMail.getState().imagesShown[email.id]),
});
}
export function signatureBlock(identity: Identity | undefined, format: "html" | "text"): string { export function signatureBlock(identity: Identity | undefined, format: "html" | "text"): string {
if (!identity) return ""; if (!identity) return "";
if (format === "text") return identity.textSignature ? `\n\n-- \n${identity.textSignature}` : ""; if (format === "text") return identity.textSignature ? `\n\n-- \n${identity.textSignature}` : "";
@@ -170,12 +246,33 @@ function defaultIdentity(identities: Identity[], email?: Email | null): Identity
return useMail.getState().defaultIdentity() ?? identities[0]; return useMail.getState().defaultIdentity() ?? identities[0];
} }
/**
* Why nothing can be written from the account in view, if it can't: a locked
* account handed to the reader without the right to send as it (inbuxa
* AL-8). Nothing is then saved to its Drafts either.
*/
export function composeBlocked(): string | null {
const s = useSession.getState();
if (!s.viewing) return null;
const delegation = delegationOf(s.session, s.viewing);
if (!delegation || delegation.sendAs) return null;
const name = s.session?.accounts[s.viewing]?.name ?? "";
return translate("You can't send from {name}. It was handed to you to read, not to send as.", { name });
}
function refuseCompose(): boolean {
const why = composeBlocked();
if (why) toast.show(why);
return why !== null;
}
export const useCompose = create<ComposeState>((set, get) => ({ export const useCompose = create<ComposeState>((set, get) => ({
drafts: [], drafts: [],
activeKey: null, activeKey: null,
pendingSends: {}, pendingSends: {},
open(init = {}) { open(init = {}) {
if (refuseCompose()) return "";
const identities = useMail.getState().identities; const identities = useMail.getState().identities;
const ident = init.identityId ? identities.find((i) => i.id === init.identityId) : useMail.getState().defaultIdentity(); const ident = init.identityId ? identities.find((i) => i.id === init.identityId) : useMail.getState().defaultIdentity();
const d = blankDraft({ identityId: ident?.id ?? null, replyTo: ident?.replyTo ?? [], showReplyTo: Boolean(ident?.replyTo?.length), ...init }); const d = blankDraft({ identityId: ident?.id ?? null, replyTo: ident?.replyTo ?? [], showReplyTo: Boolean(ident?.replyTo?.length), ...init });
@@ -201,6 +298,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
* signature from every message that started as a share. * signature from every message that started as a share.
*/ */
openFromShare(share) { openFromShare(share) {
if (refuseCompose()) return "";
const body = shareBody(share); const body = shareBody(share);
const key = get().open({ subject: share.title.trim() }); const key = get().open({ subject: share.title.trim() });
if (body) { if (body) {
@@ -212,6 +310,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
}, },
async openDraftEmail(email) { async openDraftEmail(email) {
if (refuseCompose()) return "";
const existing = get().drafts.find((d) => d.draftId === email.id); const existing = get().drafts.find((d) => d.draftId === email.id);
if (existing) { if (existing) {
get().focus(existing.key); get().focus(existing.key);
@@ -243,7 +342,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
showCc: Boolean(full.cc?.length), showCc: Boolean(full.cc?.length),
showBcc: Boolean(full.bcc?.length), showBcc: Boolean(full.bcc?.length),
subject: full.subject ?? "", subject: full.subject ?? "",
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"), html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), proxyRemote: imageProxyOn(), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
text: text || (html ? htmlToText(html) : ""), text: text || (html ? htmlToText(html) : ""),
format: html ? "html" : settings().composeFormat, format: html ? "html" : settings().composeFormat,
attachments, attachments,
@@ -273,6 +372,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
* both are new without anything here asking for it. * both are new without anything here asking for it.
*/ */
async composeAsNew(email) { async composeAsNew(email) {
if (refuseCompose()) return "";
const mail = useMail.getState(); const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email; const full = (await mail.getEmails([email.id], true))[0] ?? email;
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities(); const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
@@ -309,7 +409,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
showCc: Boolean(full.cc?.length), showCc: Boolean(full.cc?.length),
showBcc: Boolean(full.bcc?.length), showBcc: Boolean(full.bcc?.length),
subject: full.subject ?? "", subject: full.subject ?? "",
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"), html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), proxyRemote: imageProxyOn(), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
text: text || (html ? htmlToText(html) : ""), text: text || (html ? htmlToText(html) : ""),
format: html ? "html" : settings().composeFormat, format: html ? "html" : settings().composeFormat,
attachments, attachments,
@@ -324,6 +424,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
}, },
async reply(email, mode) { async reply(email, mode) {
if (refuseCompose()) return "";
const mail = useMail.getState(); const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email; const full = (await mail.getEmails([email.id], true))[0] ?? email;
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities(); const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
@@ -366,6 +467,19 @@ export const useCompose = create<ComposeState>((set, get) => ({
// Addressed only to myself, or only in Cc: there is still somebody this // Addressed only to myself, or only in Cc: there is still somebody this
// is a reply to, and an empty To is not it. // is a reply to, and an empty To is not it.
if (!to.length) { to = cc.length ? cc : withoutOwn(full.cc ?? []); cc = []; } if (!to.length) { to = cc.length ? cc : withoutOwn(full.cc ?? []); cc = []; }
/*
* Nobody but me on the message, and a Reply-To pointing somewhere that
* is not mine: that address is who this is really from.
*
* A contact form is the shape of it -- From and To are both the site's
* own mailbox, and the person who filled the form in is in Reply-To.
* The address test above calls that mine, correctly as far as it goes,
* and the fallback then addressed the reply to my own desk (#415).
*
* After the Cc, not before it: a message I really did send carries my
* own Reply-To, and somebody I actually wrote to beats it.
*/
if (!to.length) to = withoutOwn(full.replyTo ?? []);
if (!to.length) to = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]); if (!to.length) to = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]);
} else { } else {
to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? [])); to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? []));
@@ -379,6 +493,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
const textPart = full.textBody?.[0]; const textPart = full.textBody?.[0];
const origHtml = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : ""; const origHtml = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
const origText = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : ""; const origText = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
/*
* What the message being answered was really written in. `htmlBody` is
* derived, so its presence proves nothing -- hasHtmlAlternative() reads the
* part's own type. Getting this wrong would offer every plain-text message
* a switch to rich text it does not need.
*/
const origFormat = hasHtmlAlternative(htmlPart, origHtml) ? "html" : "text";
const accountId = mail.accountId!; const accountId = mail.accountId!;
const attachments: ComposeAttachment[] = []; const attachments: ComposeAttachment[] = [];
const cidMap: Record<string, string> = {}; const cidMap: Record<string, string> = {};
@@ -389,9 +510,21 @@ export const useCompose = create<ComposeState>((set, get) => ({
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline }); attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
} }
} }
/*
* Quoting renders the message a second time, so the reader's decision
* about its remote images applies here too: a quote that fetched what
* they declined would report the message read to whoever was counting
* (#410). Blocked images keep their address and get it back on the way
* out, so the recipient's copy is the quote as its sender wrote it.
*/
const allowRemote = remoteImagesForMessage(full);
// Fetched through this server while the reply is written, as reading the
// message does, and pointed back at their own addresses on the way out
// (#412).
const proxyRemote = imageProxyOn();
// Inline images are shown via their blob URLs in the editor and converted back to cid: at send time. // Inline images are shown via their blob URLs in the editor and converted back to cid: at send time.
const quotedHtmlBody = origHtml const quotedHtmlBody = origHtml
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false, dropStyleBlocks: true }).html ? sanitizeEmailHtml(origHtml, { cidMap, allowRemote, proxyRemote, dropStyleBlocks: true }).html
: textToHtml(origText).replace(/\n/g, "<br>"); : textToHtml(origText).replace(/\n/g, "<br>");
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", ")); const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
const date = formatFullDate(full.receivedAt); const date = formatFullDate(full.receivedAt);
@@ -434,12 +567,16 @@ export const useCompose = create<ComposeState>((set, get) => ({
relatedKeyword: mode === "forward" ? "$forwarded" : "$answered", relatedKeyword: mode === "forward" ? "$forwarded" : "$answered",
signatureHtml: sigHtml, signatureHtml: sigHtml,
replyMode: mode, replyMode: mode,
quoteHtml,
quoteText: quoteTxt,
formatOffer: origFormat === s.composeFormat ? null : origFormat,
}); });
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key })); set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
return d.key; return d.key;
}, },
forwardAsAttachment(email) { forwardAsAttachment(email) {
if (refuseCompose()) return "";
const accountId = useMail.getState().accountId; const accountId = useMail.getState().accountId;
const key = get().open({ const key = get().open({
subject: replySubject(email.subject, "Fwd"), subject: replySubject(email.subject, "Fwd"),
@@ -617,9 +754,21 @@ export const useCompose = create<ComposeState>((set, get) => ({
return { pendingSends: rest }; return { pendingSends: rest };
}); });
try { try {
await sendInternal(d, get); const sent = await sendInternal(d, get);
toast.success(scheduling ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent")); toast.success(
sent.held
? translate("Held for review: it's sent once a reviewer releases it")
: scheduling
? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) })
: translate("Message sent"),
);
} catch (err) { } catch (err) {
// inbuxa: DLP refused it: the draft comes back with the rules' notices
if (err instanceof DlpRefusal) {
set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: null, dlp: err.info, dlpOverride: null }], activeKey: d.key }));
toast.error(err.info.kind === "warning" ? translate("Not sent yet: check the warning") : translate("Not sent: blocked by the server's rules"));
return;
}
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), { 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 })) }, action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
duration: 15000, duration: 15000,
@@ -754,7 +903,9 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailb
if (!ident) throw new Error(translate("No sending identity available")); if (!ident) throw new Error(translate("No sending identity available"));
const from: EmailAddress = { name: ident.name || null, email: ident.email }; const from: EmailAddress = { name: ident.name || null, email: ident.email };
let html = d.format === "html" ? d.html : ""; // Images blocked when the message was quoted keep their address; the copy
// that leaves carries it, and the recipient's client decides for itself.
let html = d.format === "html" ? unproxyImages(restoreBlockedImages(d.html)) : "";
const text = d.format === "html" ? htmlToText(d.html) : d.text; const text = d.format === "html" ? htmlToText(d.html) : d.text;
// Inline attachments shown via blob URLs in the editor → back to cid: references. // Inline attachments shown via blob URLs in the editor → back to cid: references.
@@ -912,6 +1063,8 @@ export function buildSubmission(opts: {
draftsId: Id | null; draftsId: Id | null;
scheduledId: Id | null; scheduledId: Id | null;
sendAt: number | null; sendAt: number | null;
/** inbuxa: the sender's reason to send despite a DLP warning. */
dlpOverride?: string | null;
}): { create: Record<string, unknown>; onSuccessUpdateEmail: Record<string, unknown> } { }): { create: Record<string, unknown>; onSuccessUpdateEmail: Record<string, unknown> } {
const scheduled = opts.sendAt !== null; const scheduled = opts.sendAt !== null;
const mailFrom: Record<string, unknown> = { email: opts.fromEmail }; const mailFrom: Record<string, unknown> = { email: opts.fromEmail };
@@ -921,13 +1074,17 @@ export function buildSubmission(opts: {
if (filedIn) onSuccess[`mailboxIds/${filedIn}`] = true; if (filedIn) onSuccess[`mailboxIds/${filedIn}`] = true;
if (opts.draftsId && opts.draftsId !== filedIn) onSuccess[`mailboxIds/${opts.draftsId}`] = null; if (opts.draftsId && opts.draftsId !== filedIn) onSuccess[`mailboxIds/${opts.draftsId}`] = null;
if (scheduled && opts.sentId && opts.sentId !== filedIn) onSuccess[`mailboxIds/${opts.sentId}`] = null; if (scheduled && opts.sentId && opts.sentId !== filedIn) onSuccess[`mailboxIds/${opts.sentId}`] = null;
return { const create: Record<string, unknown> = { identityId: opts.identityId, emailId: opts.emailRef, envelope: { mailFrom, rcptTo: opts.rcpts } };
create: { identityId: opts.identityId, emailId: opts.emailRef, envelope: { mailFrom, rcptTo: opts.rcpts } }, if (opts.dlpOverride?.trim()) create["inbuxa:dlpOverride"] = { reason: opts.dlpOverride.trim() };
onSuccessUpdateEmail: onSuccess, return { create, onSuccessUpdateEmail: onSuccess };
};
} }
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> { /** What the server said of a send: whether DLP held it for review (inbuxa). */
interface Sent {
held: boolean;
}
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
const mail = useMail.getState(); const mail = useMail.getState();
const accountId = mail.accountId!; const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
@@ -949,6 +1106,7 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
draftsId, draftsId,
scheduledId, scheduledId,
sendAt: scheduled ? d.sendAt : null, sendAt: scheduled ? d.sendAt : null,
dlpOverride: d.dlpOverride ?? null,
}); });
const calls: Array<[string, Record<string, unknown>, string]> = [ const calls: Array<[string, Record<string, unknown>, string]> = [
["Email/set", { accountId, create: { m: email }, ...(d.draftId ? { destroy: [d.draftId] } : {}) }, "e"], ["Email/set", { accountId, create: { m: email }, ...(d.draftId ? { destroy: [d.draftId] } : {}) }, "e"],
@@ -972,6 +1130,8 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
// Clean up the created (unsent) email so it doesn't linger in Sent. // Clean up the created (unsent) email so it doesn't linger in Sent.
const created = e.created?.m?.id; const created = e.created?.m?.id;
if (created) void client.call("Email/set", { accountId, destroy: [created] }); if (created) void client.call("Email/set", { accountId, destroy: [created] });
const dlp = dlpRefusalOf(err as { type?: string; rules?: { name?: string; notice?: string }[] });
if (dlp) throw new DlpRefusal(dlp);
throw new Error(setErrorMessage(err)); throw new Error(setErrorMessage(err));
} }
if (d.relatedEmailId && d.relatedKeyword) { if (d.relatedEmailId && d.relatedKeyword) {
@@ -993,6 +1153,7 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
} }
void mail.loadMailboxes(); void mail.loadMailboxes();
void mail.refreshList(); void mail.refreshList();
return { held: (s.created?.s as { "inbuxa:held"?: boolean } | undefined)?.["inbuxa:held"] === true };
} }
export { FULL_PROPS, BODY_PROPS }; export { FULL_PROPS, BODY_PROPS };
+3 -2
View File
@@ -270,7 +270,8 @@ export const useContacts = create<ContactsState>((set, get) => ({
// The reader's own, not whichever account is selected: a shared address // The reader's own, not whichever account is selected: a shared address
// book is shown beside theirs rather than instead of it, so nothing here // book is shown beside theirs rather than instead of it, so nothing here
// should move when the switcher does. // should move when the switcher does.
const accountId = useSession.getState().ownAccountFor(CAP.contacts); // inbuxa AL-7: or the locked account in view, the whole of it
const accountId = useSession.getState().viewAccountFor(CAP.contacts);
const available = Boolean(accountId && client.hasCapability(CAP.contacts)); const available = Boolean(accountId && client.hasCapability(CAP.contacts));
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, cardState: null, loaded: false, selection: { accountId: null, bookId: "all" } }); if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, cardState: null, loaded: false, selection: { accountId: null, bookId: "all" } });
set({ available }); set({ available });
@@ -300,7 +301,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
*/ */
async loadShared() { async loadShared() {
const session = useSession.getState(); const session = useSession.getState();
const own = session.ownAccountFor(CAP.contacts); const own = session.viewAccountFor(CAP.contacts);
const s = session.session; const s = session.session;
const accounts = Object.entries(s?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own); const accounts = Object.entries(s?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
if (!accounts.length) { if (!accounts.length) {
+6 -3
View File
@@ -139,13 +139,16 @@ export const useFiles = create<FilesState>((set, get) => ({
async init() { async init() {
const session = useSession.getState(); const session = useSession.getState();
const ownAccountId = session.ownAccountFor(CAP.filenode); // inbuxa AL-7: home is the locked account in view, the whole of it
const ownAccountId = session.viewAccountFor(CAP.filenode);
const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode)); const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
// Stay where the reader is if the session still offers that account; // Stay where the reader is if the session still offers that account;
// whether it still holds files is `discoverShared`'s to say. // whether it still holds files is `discoverShared`'s to say. A new home
// (a locked account opened or left) starts there.
const browsing = get().accountId; const browsing = get().accountId;
const offered = Object.entries(session.session?.accounts ?? {}).some(([id, a]) => id === browsing && a.isPersonal === false); const offered = Object.entries(session.session?.accounts ?? {}).some(([id, a]) => id === browsing && a.isPersonal === false);
if (!(browsing && (browsing === ownAccountId || offered))) set(emptyForAccount(ownAccountId)); const moved = ownAccountId !== get().ownAccountId;
if (moved || !(browsing && (browsing === ownAccountId || offered))) set(emptyForAccount(ownAccountId));
set({ available, ownAccountId }); set({ available, ownAccountId });
}, },
+66 -3
View File
@@ -78,6 +78,7 @@ function offerArchiveFolder(retry: () => Promise<void>): void {
export const useMail = create<MailState>((set, get) => ({ export const useMail = create<MailState>((set, get) => ({
accountId: null, accountId: null,
imagesShown: {},
mailboxes: {}, mailboxes: {},
mailboxState: null, mailboxState: null,
mailboxesLoaded: false, mailboxesLoaded: false,
@@ -104,6 +105,15 @@ export const useMail = create<MailState>((set, get) => ({
setAccount(accountId) { setAccount(accountId) {
if (accountId === get().accountId) return; if (accountId === get().accountId) return;
// inbuxa AL-7: leaving the reader's own mail for a delegated account,
// remember where theirs was, so new mail there is still announced
const own = useSession.getState().accountId;
const leaving = get().accountId;
if (leaving && leaving === own && accountId && accountId !== own) {
ownWhileAway = { accountId: own, inbox: get().roleId("inbox"), state: get().emailState };
} else if (accountId === own) {
ownWhileAway = null;
}
resetBodyOrder(); resetBodyOrder();
snapshots.clear(); snapshots.clear();
set({ set({
@@ -866,6 +876,10 @@ export const useMail = create<MailState>((set, get) => ({
} }
}, },
showImages(id) {
set((s) => ({ imagesShown: { ...s.imagesShown, [id]: true } }));
},
select(ids, on) { select(ids, on) {
set((s) => { set((s) => {
const next = { ...s.selected }; const next = { ...s.selected };
@@ -1485,10 +1499,56 @@ function removeFromList(ids: Id[], set: (fn: (s: MailState) => Partial<MailState
} }
async function notifyNewMail(created: Id[], get: () => MailState) { async function notifyNewMail(created: Id[], get: () => MailState) {
const s = settings();
const inbox = get().roleId("inbox"); const inbox = get().roleId("inbox");
if (!inbox) return; if (!inbox) return;
const emails = await get().getEmails(created); const emails = await get().getEmails(created);
announceNewMail(emails, inbox);
}
/**
* The reader's own account while a delegated one is in view (inbuxa AL-7):
* its inbox and how far its mail was seen, so what arrives there meanwhile
* is still announced.
*/
let ownWhileAway: { accountId: Id; inbox: Id | null; state: string | null } | null = null;
/** New mail in the reader's own account, while a delegated one is in view. */
export async function notifyOwnWhileAway(): Promise<void> {
const away = ownWhileAway;
if (!away?.inbox || !away.state) return;
try {
const changes = await client.call<ChangesResponse>("Email/changes", {
accountId: away.accountId,
sinceState: away.state,
maxChanges: 50,
});
if (ownWhileAway !== away) return;
away.state = changes.newState;
if (!changes.created.length) return;
const got = await client.call<GetResponse<Email>>("Email/get", {
accountId: away.accountId,
ids: changes.created,
properties: LIST_PROPS,
});
announceNewMail(got.list, away.inbox);
} catch {
/* the next change tries again */
}
}
export function ownAccountAway(): Id | null {
return ownWhileAway?.accountId ?? null;
}
/** The reader's own inbox, whatever account is in view (inbuxa AL-7). */
export function ownInboxId(): Id | null {
const mail = useMail.getState();
if (mail.accountId === useSession.getState().accountId) return mail.roleId("inbox");
return ownWhileAway?.inbox ?? null;
}
function announceNewMail(emails: Email[], inbox: Id) {
const s = settings();
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft); const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
if (!fresh.length) return; if (!fresh.length) return;
if (s.notificationSound) playNewMailSound(); if (s.notificationSound) playNewMailSound();
@@ -1514,9 +1574,12 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
} }
} }
/** Keep the store bound to the selected account. */ /**
* Keep the store bound to the account in view: a delegated account while one
* is open (inbuxa AL-7), the reader's own otherwise.
*/
useSession.subscribe((s) => { useSession.subscribe((s) => {
useMail.getState().setAccount(s.status === "authenticated" ? s.accountId : null); useMail.getState().setAccount(s.status === "authenticated" ? (s.viewing ?? s.accountId) : null);
}); });
+3
View File
@@ -1,4 +1,5 @@
import { SPAM_HEADER_PROPS } from "@/lib/spamScore"; import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
import { LLM_HEADER_PROP } from "@/lib/llmOpinion";
/* /*
@@ -67,6 +68,8 @@ export const FULL_PROPS = [
"header:Precedence:asText", "header:Precedence:asText",
"header:Authentication-Results:asText", "header:Authentication-Results:asText",
...SPAM_HEADER_PROPS, ...SPAM_HEADER_PROPS,
// inbuxa: the language model's opinion, when the server wrote one
LLM_HEADER_PROP,
]; ];
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"]; export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
+8
View File
@@ -33,6 +33,12 @@ export interface ListState extends ListQuery {
export interface MailState { export interface MailState {
accountId: Id | null; accountId: Id | null;
/**
* Messages the reader pressed "Show images" on, this session. Kept here
* rather than in the message view because replying quotes the message into
* a second window, which has to honour the same decision.
*/
imagesShown: Record<Id, boolean>;
mailboxes: Record<Id, Mailbox>; mailboxes: Record<Id, Mailbox>;
mailboxState: string | null; mailboxState: string | null;
mailboxesLoaded: boolean; mailboxesLoaded: boolean;
@@ -113,6 +119,8 @@ export interface MailState {
saveVacation(patch: Partial<VacationResponse>): Promise<void>; saveVacation(patch: Partial<VacationResponse>): Promise<void>;
loadQuota(): Promise<void>; loadQuota(): Promise<void>;
/** Remember that this message's remote images were allowed by hand. */
showImages(id: Id): void;
select(ids: Id[], on: boolean): void; select(ids: Id[], on: boolean): void;
clearSelection(): void; clearSelection(): void;
/** Refresh the per-label unread counts, in one request. */ /** Refresh the per-label unread counts, in one request. */
+60 -3
View File
@@ -9,6 +9,7 @@ import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild";
import { unsubscribeThisDevice } from "@/lib/notify/webpush"; import { unsubscribeThisDevice } from "@/lib/notify/webpush";
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage"; import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout"; import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
import { delegationOf, type Delegation } from "@/lib/delegation";
export type AuthStatus = "loading" | "anonymous" | "authenticated"; export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -17,6 +18,14 @@ interface SessionState {
session: JmapSession | null; session: JmapSession | null;
/** Selected mail account (defaults to primary). */ /** Selected mail account (defaults to primary). */
accountId: Id | null; 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.
*/
viewing: Id | null;
/** The name of an account whose delegation ended while it was in view. */
delegationEnded: string | null;
error: string | null; error: string | null;
pushConnected: boolean; pushConnected: boolean;
/** Finer than pushConnected: tells "reconnecting" from "not connected". */ /** Finer than pushConnected: tells "reconnecting" from "not connected". */
@@ -26,10 +35,20 @@ interface SessionState {
logout(): Promise<void>; logout(): Promise<void>;
refresh(): Promise<void>; refresh(): Promise<void>;
setAccount(id: Id): void; setAccount(id: Id): void;
/** Show a delegated account's mail, or the reader's own with null. */
view(id: Id | null): void;
clearDelegationEnded(): void;
/** The account to read and write for a capability, honoring the account switcher. */ /** The account to read and write for a capability, honoring the account switcher. */
accountFor(cap: string): Id | null; accountFor(cap: string): Id | null;
/** The user's own account for a capability, whatever they are looking at. */ /** The user's own account for a capability, whatever they are looking at. */
ownAccountFor(cap: string): Id | null; ownAccountFor(cap: string): Id | null;
/**
* inbuxa AL-7: the account calendar, contacts and files show: the locked
* account in view, if it offers `cap`, else the reader's own. Never for
* anything the reader keeps (settings, signatures, push): those stay
* `ownAccountFor`.
*/
viewAccountFor(cap: string): Id | null;
} }
let refreshing: Promise<void> | null = null; let refreshing: Promise<void> | null = null;
@@ -38,6 +57,8 @@ export const useSession = create<SessionState>((set, get) => ({
status: "loading", status: "loading",
session: null, session: null,
accountId: null, accountId: null,
viewing: null,
delegationEnded: null,
error: null, error: null,
pushConnected: false, pushConnected: false,
pushState: "disconnected", pushState: "disconnected",
@@ -99,7 +120,7 @@ export const useSession = create<SessionState>((set, get) => ({
// problem next -- and the address book cached here is the same argument. // problem next -- and the address book cached here is the same argument.
clearSignedInData(); clearSignedInData();
client.session = null; client.session = null;
set({ status: "anonymous", session: null, accountId: null }); set({ status: "anonymous", session: null, accountId: null, viewing: null });
}, },
refresh() { refresh() {
@@ -109,7 +130,14 @@ export const useSession = create<SessionState>((set, get) => ({
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1"); const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
client.session = s; client.session = s;
setServerLocale(s.ihasmail?.userLocale); setServerLocale(s.ihasmail?.userLocale);
// A delegation that ended takes the reader back to their own mail
const viewing = get().viewing;
if (viewing && !delegationOf(s, viewing)) {
const name = get().session?.accounts[viewing]?.name ?? null;
set({ session: s, viewing: null, delegationEnded: name });
} else {
set({ session: s }); set({ session: s });
}
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {
@@ -123,6 +151,16 @@ export const useSession = create<SessionState>((set, get) => ({
set({ accountId: id }); set({ accountId: id });
}, },
view(id) {
if (id && !delegationOf(get().session, id)) return;
if (id === get().viewing) return;
set({ viewing: id });
},
clearDelegationEnded() {
set({ delegationEnded: null });
},
accountFor(cap) { accountFor(cap) {
return accountForCapability(get().session, get().accountId, cap); return accountForCapability(get().session, get().accountId, cap);
}, },
@@ -130,6 +168,13 @@ export const useSession = create<SessionState>((set, get) => ({
ownAccountFor(cap) { ownAccountFor(cap) {
return ownAccountForCapability(get().session, cap); return ownAccountForCapability(get().session, cap);
}, },
viewAccountFor(cap) {
const { session, viewing } = get();
const viewed = viewing ? session?.accounts[viewing] : undefined;
if (viewing && viewed && cap in (viewed.accountCapabilities ?? {})) return viewing;
return ownAccountForCapability(session, cap);
},
})); }));
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) { function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
@@ -149,7 +194,7 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
startIdleLogout(() => void useSession.getState().logout()); startIdleLogout(() => void useSession.getState().logout());
} }
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null; const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
set({ status: "authenticated", session: s, accountId, error: null }); set({ status: "authenticated", session: s, accountId, viewing: null, error: null });
} }
client.onUnauthenticated(() => { client.onUnauthenticated(() => {
@@ -162,12 +207,24 @@ client.onUnauthenticated(() => {
// usual reason to be signed out here, and reloading a form someone has // usual reason to be signed out here, and reloading a form someone has
// already started typing into would throw the password away. // already started typing into would throw the password away.
void reloadIfServerRebuilt().then((reloading) => { void reloadIfServerRebuilt().then((reloading) => {
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null }); if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null });
}); });
}); });
push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state })); push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state }));
/** The delegation of the locked account in view, if one is (inbuxa AL-6). */
export function useViewingDelegation(): Delegation | null {
const session = useSession((s) => s.session);
const viewing = useSession((s) => s.viewing);
return delegationOf(session, viewing);
}
export function viewingDelegation(): Delegation | null {
const s = useSession.getState();
return delegationOf(s.session, s.viewing);
}
export function hasCap(cap: string): boolean { export function hasCap(cap: string): boolean {
return client.hasCapability(cap); return client.hasCapability(cap);
} }
+42
View File
@@ -1248,6 +1248,20 @@ a.menu-item:hover { color: var(--fg); }
.topbar .brand img { width: 34px; height: 34px; object-fit: contain; } .topbar .brand img { width: 34px; height: 34px; object-fit: contain; }
.topbar .brand .brand-name { color: #14b8a6; } .topbar .brand .brand-name { color: #14b8a6; }
.topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; } .topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; }
/*
* inbuxa AL-7: a locked account's mail in view. The bar is its own grid row
* above the top bar; its red is fixed, never the palette's, so it reads the
* same in every palette and mode and can't pass for part of a theme (white on
* red-700 is 6.5:1). The wordmark turns red too, with a padlock beside it.
*/
.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; }
.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; }
[data-theme="dark"] .topbar .brand.locked .brand-name, [data-theme="dark"] .topbar .brand.locked .brand-lock { color: #f87171; }
.topbar .brand .brand-lock { flex: none; }
/* `min-width: 0`, or the flex default of `auto` holds the search field at its /* `min-width: 0`, or the flex default of `auto` holds the search field at its
own min-content and the account buttons beside it are pushed off a phone. */ own min-content and the account buttons beside it are pushed off a phone. */
.searchbar { flex: 1 1 auto; min-width: 0; max-width: 720px; margin: 0 auto; position: relative; } .searchbar { flex: 1 1 auto; min-width: 0; max-width: 720px; margin: 0 auto; position: relative; }
@@ -1461,6 +1475,11 @@ a.menu-item:hover { color: var(--fg); }
.mail-list.two-line .msg-row .msg-from { width: auto; flex: 1; } .mail-list.two-line .msg-row .msg-from { width: auto; flex: 1; }
.mail-list.two-line .msg-row .msg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .mail-list.two-line .msg-row .msg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.mail-list.two-line .msg-row .msg-line1 { display: flex; align-items: center; gap: 8px; } .mail-list.two-line .msg-row .msg-line1 { display: flex; align-items: center; gap: 8px; }
/* Labels share the sender's line (#35). The sender gives way first, then the
labels, each of which truncates rather than pushing the date off the row. */
.mail-list.two-line .msg-row .msg-line1 .msg-from { flex: 0 1 auto; min-width: 3em; }
.mail-list.two-line .msg-row .msg-line1 .msg-labels { flex: 0 1 auto; min-width: 0; max-width: 55%; overflow: hidden; }
.mail-list.two-line .msg-row .msg-line1 .msg-labels .tag { flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; display: block; line-height: 18px; }
.mail-list.two-line .msg-row .msg-main { display: flex; gap: 6px; } .mail-list.two-line .msg-row .msg-main { display: flex; gap: 6px; }
.mail-list.two-line .msg-row .msg-subject { flex: 0 1 auto; } .mail-list.two-line .msg-row .msg-subject { flex: 0 1 auto; }
.mail-list.two-line .msg-row .msg-preview { flex: 1; } .mail-list.two-line .msg-row .msg-preview { flex: 1; }
@@ -1570,6 +1589,12 @@ a.menu-item:hover { color: var(--fg); }
.composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; } .composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; }
.composer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; } .composer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
/* An offer the draft makes about itself, above the editor: quiet, one line, and dismissible. */
.composer-notice { display: flex; align-items: center; gap: 8px; padding: 6px 10px 6px 14px; background: var(--accent-soft); color: var(--accent-soft-fg); border-bottom: 1px solid var(--border); font-size: .85em; flex: 0 0 auto; }
.composer-notice span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* inbuxa: a DLP warning or block on the last send */
.composer-notice-dlp { background: var(--warn-soft); color: var(--fg); }
.composer-notice-dlp span { white-space: normal; }
.composer-fields { flex: 0 0 auto; padding: 0 12px; } .composer-fields { flex: 0 0 auto; padding: 0 12px; }
.composer-field { display: flex; align-items: center; gap: 8px; min-height: 40px; border-bottom: 1px solid var(--border); padding: 4px 0; } .composer-field { display: flex; align-items: center; gap: 8px; min-height: 40px; border-bottom: 1px solid var(--border); padding: 4px 0; }
.composer-field > label { color: var(--fg-muted); width: 42px; flex: 0 0 auto; font-size: .92em; } .composer-field > label { color: var(--fg-muted); width: 42px; flex: 0 0 auto; font-size: .92em; }
@@ -1602,6 +1627,14 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; } .editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); } .editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
.editor-area img { max-width: 100%; height: auto; } .editor-area img { max-width: 100%; height: auto; }
/* Image resizing (Gitea issue #26): drawn over the editor, never inside it, so none of it is sent. */
.img-resize-layer { position: absolute; pointer-events: none; overflow: hidden; z-index: 5; }
.img-resize-frame { position: absolute; outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.img-resize-handle { position: absolute; right: -7px; bottom: -7px; width: 14px; height: 14px; background: var(--accent); border: 2px solid var(--bg-elev); border-radius: 3px; cursor: nwse-resize; pointer-events: auto; touch-action: none; }
.img-resize-bar { position: absolute; display: flex; gap: 4px; padding: 3px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: var(--shadow-2); pointer-events: auto; }
.img-resize-bar .btn-sm { height: 26px; padding: 0 8px; }
.img-resize-bar .btn[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
@media (pointer: coarse) { .img-resize-handle { width: 24px; height: 24px; right: -12px; bottom: -12px; border-radius: 50%; } }
.editor-area a { color: var(--link); } .editor-area a { color: var(--link); }
.editor-area .ihm-signature { color: var(--fg-muted); } .editor-area .ihm-signature { color: var(--fg-muted); }
.editor-area pre { font-family: var(--font-mono); background: var(--bg-sunken); padding: 8px; border-radius: 6px; overflow: auto; } .editor-area pre { font-family: var(--font-mono); background: var(--bg-sunken); padding: 8px; border-radius: 6px; overflow: auto; }
@@ -1682,6 +1715,8 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.shortcut-grid h3 { margin: 0 0 6px; font-size: .9em; text-transform: uppercase; letter-spacing: .05em; color: var(--fg-faint); } .shortcut-grid h3 { margin: 0 0 6px; font-size: .9em; text-transform: uppercase; letter-spacing: .05em; color: var(--fg-faint); }
.shortcut-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; gap: 12px; } .shortcut-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; gap: 12px; }
.shortcut-row .keys { display: flex; gap: 4px; } .shortcut-row .keys { display: flex; gap: 4px; }
/* ihasmail-inbuxa: the product name in a sentence, set apart in the brand teal. */
.brand-inbuxa { color: #14b8a6; font-weight: 700; }
.sessions-table { width: 100%; border-collapse: collapse; font-size: .92em; } .sessions-table { width: 100%; border-collapse: collapse; font-size: .92em; }
.sessions-table th, .sessions-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); } .sessions-table th, .sessions-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.sessions-table th { color: var(--fg-muted); font-weight: 600; font-size: .85em; } .sessions-table th { color: var(--fg-muted); font-weight: 600; font-size: .85em; }
@@ -2387,6 +2422,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.spam-weight.bad { color: var(--danger); } .spam-weight.bad { color: var(--danger); }
.spam-weight.good { color: var(--success); } .spam-weight.good { color: var(--success); }
/* inbuxa: the language model's opinion, in the details and above a message in
Junk (views/mail/LlmOpinion.tsx). The explanation is the model's own words,
so it keeps its line breaks out and wraps rather than widening the pane. */
.llm-opinion { display: flex; flex-direction: column; gap: 4px; }
.llm-heading { display: inline-flex; flex-wrap: wrap; gap: .4em; align-items: baseline; }
.llm-explanation { overflow-wrap: anywhere; }
/* The placeholder reference under a template's body. */ /* The placeholder reference under a template's body. */
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; } .placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
.placeholder-row { display: contents; } .placeholder-row { display: contents; }
+4 -4
View File
@@ -1,9 +1,9 @@
/** /**
* The INBUXA wordmark, "inbuxa" in Space Grotesk Bold, as vector paths in the * The INBUXA wordmark, "inbuxa" in Space Grotesk Bold, as vector paths in the
* current text color, so it reads on every palette, light or dark. The mark * current text color, so it reads on every palette, light or dark. The mark
* beside it is `/img/inbuxa-mark.png`: ihasmail's own cat and envelope, which * beside it is `/img/inbuxa-mark.png`: inbuxa's own kitten, the family's cat
* INBUXA's logo reuses unchanged. The paths are the same ones INBUXA Admin * over a server with a bay for each piece of the suite. The paths are the
* draws, from the INBUXA logo bundle. * same ones inbuxa Admin draws, from the inbuxa logo bundle.
* *
* ihasmail-inbuxa only. Public ihasmail keeps its own name. * ihasmail-inbuxa only. Public ihasmail keeps its own name.
*/ */
@@ -15,7 +15,7 @@ export function InbuxaWordmark({ className, height }: { className?: string; heig
height={height} height={height}
width={(height * 488) / 112} width={(height * 488) / 112}
role="img" role="img"
aria-label="INBUXA" aria-label="inbuxa"
className={`notranslate ${className ?? ""}`} className={`notranslate ${className ?? ""}`}
fill="currentColor" fill="currentColor"
> >
+55 -10
View File
@@ -1,14 +1,16 @@
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react"; import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, 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, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { withBase } from "@/lib/basePath"; import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
import { DEFAULT_APP_NAME } from "@/lib/brand";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
import { useEffectiveTheme, useSettings } from "@/store/settings"; import { useEffectiveTheme, useSettings } from "@/store/settings";
import { toggleTarget } from "@/lib/palette"; import { toggleTarget } from "@/lib/palette";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose"; import { composeBlocked, draftFromMailto, useCompose } from "@/store/compose";
import { delegatedAccounts } from "@/lib/delegation";
import { toast } from "@/ui/toast";
import { DelegatedBar } from "./DelegatedBar";
import { Avatar, useIsMobile } from "@/ui/misc"; import { Avatar, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Splitter } from "@/ui/Splitter"; import { Splitter } from "@/ui/Splitter";
@@ -131,24 +133,45 @@ export function AppShell({ children }: { children: ReactNode }) {
}, [openShare, navigate]); }, [openShare, navigate]);
/* /*
* There is no account switcher any more. * There is no general account switcher.
* *
* It existed to reach what other people shared, and was the wrong door: it * It existed to reach what other people shared, and was the wrong door: it
* moved the whole app to somebody else's account, and Stalwart advertises * moved the whole app to somebody else's account, and Stalwart advertises
* every capability on a shared account, so mail, calendar and contacts went * every capability on a shared account, so mail, calendar and contacts went
* with it and were refused. Shares are listed where they belong now -- in * with it and were refused. Shares are listed where they belong now -- in
* Files and in Contacts, beside the reader's own -- and found without anyone * Files and in Contacts, beside the reader's own.
* having to know an account switch was involved. *
* inbuxa AL-7 brings back one narrow door: a locked account an administrator
* handed to the reader. Only its mail is shown, in place of the reader's
* own; calendars, contacts, files and settings stay theirs. It is offered
* only when the session marks such an account, and only then is there
* anything to switch.
*/ */
const viewing = useSession((s) => s.viewing);
const delegated = delegatedAccounts(session);
const switchTo = (id: string | null) => {
acctMenu.close();
if (id === viewing) return;
// 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;
}
useSession.getState().view(id);
navigate("/mail");
};
const composeOff = section !== "files" && section !== "calendar" && section !== "contacts" && composeBlocked() !== null;
return ( return (
<div className="app"> <div className={`app ${viewing ? "delegated" : ""}`}>
<DelegatedBar />
<header className="topbar"> <header className="topbar">
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}> <button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
<MenuIcon size={22} /> <MenuIcon size={22} />
</button> </button>
<Link href="/mail" className="brand"> <Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
<img src={withBase(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" /> <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")} />}
{/* A product name, not a word: translated it is a different product. {/* A product name, not a word: translated it is a different product.
Read from the session rather than written here, so a deployment Read from the session rather than written here, so a deployment
that set APP_NAME is called what it calls itself -- the document that set APP_NAME is called what it calls itself -- the document
@@ -187,6 +210,27 @@ export function AppShell({ children }: { children: ReactNode }) {
</div> </div>
</div> </div>
<MenuSep /> <MenuSep />
{delegated.length > 0 && (
<>
<div className="hint" style={{ padding: "4px 10px" }}>{t("Mail to show")}</div>
<MenuItem
icon={viewing ? <Mail size={16} /> : <Check size={16} />}
label={t("My mail")}
active={!viewing}
onClick={() => switchTo(null)}
/>
{delegated.map((account) => (
<MenuItem
key={account.id}
icon={viewing === account.id ? <Check size={16} /> : <Lock size={16} />}
label={<span className="notranslate" translate="no">{account.name}</span>}
active={viewing === account.id}
onClick={() => switchTo(account.id)}
/>
))}
<MenuSep />
</>
)}
{/* The project site. It is linked from the login screen footer, which {/* The project site. It is linked from the login screen footer, which
is a page a signed-in user never sees again -- so from inside the is a page a signed-in user never sees again -- so from inside the
app there was no way back to it. app there was no way back to it.
@@ -250,6 +294,7 @@ export function AppShell({ children }: { children: ReactNode }) {
from the file manager and was the one thing nobody wanted there. */} from the file manager and was the one thing nobody wanted there. */}
<button <button
className="compose-btn" className="compose-btn"
hidden={composeOff}
onClick={() => { onClick={() => {
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event")); if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact")); else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
+50
View File
@@ -0,0 +1,50 @@
import { Lock } from "lucide-react";
import { useLocation } from "wouter";
import { useSession, useViewingDelegation } from "@/store/session";
import { t } from "@/lib/i18n";
import type { DelegationAccess } from "@/lib/delegation";
export function accessText(access: DelegationAccess): string {
switch (access) {
case "read":
return t("Read only");
case "organize":
return t("Read and organize");
case "full":
return t("Full access");
}
}
/**
* inbuxa AL-7: while a locked account's mail is in view, a red bar across
* the whole app says so, with the way back. Red, not the palette's accent: a
* fixed color that reads the same in every palette and in dark mode, so it
* can't be mistaken for part of a theme.
*/
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 [, navigate] = useLocation();
if (!viewing || !delegation) return null;
return (
<div className="delegated-bar" role="status">
<Lock size={15} aria-hidden />
<span className="grow truncate">
{t("Locked account:")} <strong className="notranslate" translate="no">{name}</strong>
{" · "}
{accessText(delegation.access)}
{delegation.sendAs ? ` · ${t("You can send as this account")}` : ""}
</span>
<button
type="button"
onClick={() => {
useSession.getState().view(null);
navigate("/mail");
}}
>
{t("Back to my mail")}
</button>
</div>
);
}
+3 -3
View File
@@ -5,7 +5,7 @@ import { ApiError } from "@/jmap/client";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { APP_VERSION } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { DEFAULT_APP_NAME } from "@/lib/brand"; import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -92,7 +92,7 @@ export function LoginPage() {
<div className="login-page"> <div className="login-page">
<form className="login-card" onSubmit={submit}> <form className="login-card" onSubmit={submit}>
<div className="logo"> <div className="logo">
<img src={withBase(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" width={120} height={143} /> <img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" width={120} height={126} />
{/* A product name, not a word: not translated, and not guessed at {/* A product name, not a word: not translated, and not guessed at
from the page it is on. INBUXA's is its wordmark. */} from the page it is on. INBUXA's is its wordmark. */}
<h1 className="notranslate" translate="no"> <h1 className="notranslate" translate="no">
@@ -153,7 +153,7 @@ export function LoginPage() {
margin-top, which a second paragraph would repeat as a gap. margin-top, which a second paragraph would repeat as a gap.
*/} */}
{/* ihasmail-inbuxa: this build is INBUXA's webmail, and its site is INBUXA's. */} {/* ihasmail-inbuxa: this build is INBUXA's webmail, and its site is INBUXA's. */}
<span className="notranslate" translate="no">INBUXA webmail v{APP_VERSION}</span> <span className="notranslate" translate="no">inbuxa webmail v{APP_VERSION}</span>
<br /> <br />
<a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a> <a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a>
{" · "} {" · "}
+17 -56
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Copy, Dices, KeyRound, Lock, Plus, Trash2, X } from "lucide-react"; import { Copy, Dices, ExternalLink, KeyRound, Lock, Plus, X } from "lucide-react";
import { import {
ADMIN_BASELINE, ADMIN_BASELINE,
can, can,
@@ -12,7 +12,6 @@ import {
aliasList, aliasList,
createAccount, createAccount,
describeDirectoryError, describeDirectoryError,
destroyAccount,
hasPassword, hasPassword,
passwordPatch, passwordPatch,
quotasWithDisk, quotasWithDisk,
@@ -25,7 +24,7 @@ import { formatSize } from "@/lib/format";
import { t, tNode } from "@/lib/i18n"; import { t, tNode } from "@/lib/i18n";
import { Link } from "wouter"; import { Link } from "wouter";
import { Avatar } from "@/ui/misc"; import { Avatar } from "@/ui/misc";
import { Dialog } from "@/ui/dialog"; import { useSession } from "@/store/session";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { isSelf, roleName, type DirectoryContext } from "./directoryContext"; import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
import { usePermissions } from "./usePermissions"; import { usePermissions } from "./usePermissions";
@@ -39,7 +38,6 @@ interface Props {
onClose: () => void; onClose: () => void;
onChanged: () => void; onChanged: () => void;
onCreated: (id: string) => void; onCreated: (id: string) => void;
onDeleted: () => void;
} }
/** A role as one select value: "User", "Admin", or "custom:<ids>". */ /** A role as one select value: "User", "Admin", or "custom:<ids>". */
@@ -71,7 +69,7 @@ const bytesOf = (gib: string) => {
* a password and a delete are their own calls, because each is a decision of * a password and a delete are their own calls, because each is a decision of
* its own and should never ride along with a renamed display name. * its own and should never ride along with a renamed display name.
*/ */
export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDeleted }: Props) { export function AccountSheet({ account, ctx, onClose, onChanged, onCreated }: Props) {
const perms = usePermissions(); const perms = usePermissions();
const creating = account === null; const creating = account === null;
const self = account ? isSelf(account, ctx) : false; const self = account ? isSelf(account, ctx) : false;
@@ -288,9 +286,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
{error && <p className="admin-notice error" role="alert">{error}</p>} {error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && can(perms, "Account", "Destroy") && ( {!creating && can(perms, "Account", "Destroy") && <DeleteInConsole accountId={account.id} />}
<DeleteAccount account={account} blocked={self ? t("You can't delete the account you're signed in with.") : locked ? t("This account has permissions yours doesn't.") : null} onDeleted={onDeleted} />
)}
</div> </div>
{editable && ( {editable && (
@@ -433,59 +429,24 @@ export function Aliases({ aliases, setAliases, editable, domains, defaultDomain,
); );
} }
function DeleteAccount({ account, blocked, onDeleted }: { account: DirectoryAccount; blocked: string | null; onDeleted: () => void }) { /**
const [open, setOpen] = useState(false); * inbuxa: deleting a person's account is the console's, beside locking it
const [typed, setTyped] = useState(""); * and legal holds: it asks why, for the audit log, and says when a hold
const [busy, setBusy] = useState(false); * keeps the data. Only the pointer is here.
const [error, setError] = useState<string | null>(null); */
const address = account.emailAddress ?? account.name; function DeleteInConsole({ accountId }: { accountId: string }) {
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
return ( return (
<> <>
<h3>{t("Delete")}</h3> <h3>{t("Delete")}</h3>
<div className="admin-danger"> <div className="admin-danger">
<p>{blocked ?? t("Deletes the mailbox and everything in it.")}</p> <p>{t("Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}> {adminUrl && (
<Trash2 size={14} /> {t("Delete account…")} <a className="btn btn-sm" href={`${adminUrl.replace(/\/$/, "")}/Management/x:Account/User/${accountId}`} target="_blank" rel="noopener noreferrer">
</button> <ExternalLink size={14} /> {t("Open in the console")}
</a>
)}
</div> </div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={t("Delete {address}?", { address })}
size="sm"
footer={
<>
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || typed.trim().toLowerCase() !== address.toLowerCase()}
onClick={async () => {
setBusy(true);
setError(null);
try {
await destroyAccount(account.id);
toast.success(t("Deleted {address}", { address }));
setOpen(false);
onDeleted();
} catch (err) {
setError(describeDirectoryError(err));
} finally {
setBusy(false);
}
}}
>
{t("Delete account")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>{t("This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.")}</p>
<div className="field">
<label htmlFor="admin-delete-confirm">{t("Type {address} to confirm", { address })}</label>
<input id="admin-delete-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
</Dialog>
</> </>
); );
} }
-4
View File
@@ -217,10 +217,6 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
changed(); changed();
navigate(`/admin/accounts/${id}`); navigate(`/admin/accounts/${id}`);
}} }}
onDeleted={() => {
changed();
close();
}}
/> />
)} )}
</div> </div>
+11 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Link } from "wouter"; import { Link } from "wouter";
import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, ShieldCheck, Users } from "lucide-react"; import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, ShieldCheck, Users } from "lucide-react";
import { legacyProtocolsOff } from "@/jmap/client"; import { legacyProtocolsOff, legacyProtocolsPartlyOff } from "@/jmap/client";
import { useAppName } from "@/lib/brand"; import { useAppName } from "@/lib/brand";
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/admin/adminAccess"; import { adminSections, dashboardCards, type DashboardCard } from "@/lib/admin/adminAccess";
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summarizeMetrics, type MessageStats } from "@/lib/admin/adminDashboard"; import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summarizeMetrics, type MessageStats } from "@/lib/admin/adminDashboard";
@@ -38,6 +38,7 @@ export function AdminDashboard() {
const perms = usePermissions(); const perms = usePermissions();
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null); const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
const legacyOff = useSession((s) => legacyProtocolsOff(s.session, s.accountId)); const legacyOff = useSession((s) => legacyProtocolsOff(s.session, s.accountId));
const legacyPartlyOff = useSession((s) => legacyProtocolsPartlyOff(s.session, s.accountId).join(", "));
const app = useAppName(); const app = useAppName();
const cards = dashboardCards(perms); const cards = dashboardCards(perms);
const sections = adminSections(perms); const sections = adminSections(perms);
@@ -113,6 +114,13 @@ export function AdminDashboard() {
<span>{t("Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.", { app })}</span> <span>{t("Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.", { app })}</span>
</p> </p>
)} )}
{legacyPartlyOff && (
// INBUXA: one switch per protocol, some of them off
<p className="admin-notice">
<ShieldCheck size={16} />
<span>{t("Some legacy mail protocols are off for your organization: {protocols}.", { protocols: legacyPartlyOff })}</span>
</p>
)}
{shown.length ? ( {shown.length ? (
<div className="admin-cards-wrap"> <div className="admin-cards-wrap">
<div className="admin-cards"> <div className="admin-cards">
@@ -126,12 +134,12 @@ export function AdminDashboard() {
more numbers will be: this is a glance, and operating the server is more numbers will be: this is a glance, and operating the server is
Stalwart's own administration. The link is the operator's to give. */} Stalwart's own administration. The link is the operator's to give. */}
<p className="hint admin-dashboard-note"> <p className="hint admin-dashboard-note">
{t("Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.")} {t("Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.")}
{adminUrl && ( {adminUrl && (
<> <>
{" "} {" "}
<a href={adminUrl} target="_blank" rel="noopener noreferrer"> <a href={adminUrl} target="_blank" rel="noopener noreferrer">
{t("Open INBUXA Admin")} <ExternalLink size={13} aria-hidden="true" /> {t("Open inbuxa Admin")} <ExternalLink size={13} aria-hidden="true" />
</a> </a>
</> </>
)} )}
+1 -1
View File
@@ -186,7 +186,7 @@ export function RoleSheet({ role, roles, defaults, entries, permissionsError, on
{!creating && can(perms, "Role", "Destroy") && ( {!creating && can(perms, "Role", "Destroy") && (
<DeleteRole <DeleteRole
role={role} role={role}
blocked={kinds.length ? t("The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.") : locked ? t("This role carries permissions yours doesn't.") : null} blocked={kinds.length ? t("The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.") : locked ? t("This role carries permissions yours doesn't.") : null}
onDeleted={onDeleted} onDeleted={onDeleted}
/> />
)} )}
@@ -68,10 +68,15 @@ export function TenantLegacyProtocols({ tenantId, tenantName }: { tenantId: stri
<p> <p>
{state.off {state.off
? t("Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.", { tenant: tenantName, app }) ? t("Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.", { tenant: tenantName, app })
: state.partlyOff.length > 0
? t("Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.", {
tenant: tenantName,
protocols: state.partlyOff.join(", "),
})
: t("On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.", { tenant: tenantName })} : t("On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.", { tenant: tenantName })}
</p> </p>
{canChange && state.off && ( {canChange && (state.off || state.partlyOff.length > 0) && (
<button className="btn" disabled={busy} onClick={() => void turn(false)}> <button className="btn" disabled={busy} onClick={() => void turn(false)}>
{t("Turn legacy protocols back on")} {t("Turn legacy protocols back on")}
</button> </button>
@@ -15,7 +15,7 @@ const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
function signIn(permissions: string[], username = "[email protected]") { function signIn(permissions: string[], username = "[email protected]") {
useSession.setState({ useSession.setState({
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:stalwart:jmap": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession, session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:inbuxa:jmap:registry": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
}); });
} }
@@ -48,7 +48,7 @@ describe("the account sheet", () => {
await act(async () => { await act(async () => {
root.render( root.render(
<Router hook={hook}> <Router hook={hook}>
<AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} /> <AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} />
</Router>, </Router>,
); );
}); });
@@ -89,7 +89,20 @@ describe("the account sheet", () => {
expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull(); expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull();
expect(button(host, "Set a new password")).toBeUndefined(); expect(button(host, "Set a new password")).toBeUndefined();
expect((host.querySelector('select[aria-label="Role"]') as HTMLSelectElement).disabled).toBe(true); expect((host.querySelector('select[aria-label="Role"]') as HTMLSelectElement).disabled).toBe(true);
expect(button(host, "Delete account")?.disabled).toBe(true); expect(host.textContent).not.toContain("Delete account");
expect(host.textContent).toContain("administration console");
});
it("sends deleting a person to the console, opened on that account (inbuxa)", async () => {
signIn([...HELPDESK, "sysAccountDestroy"]);
const s = useSession.getState().session!;
useSession.setState({
session: { ...s, ihasmail: { ...s.ihasmail, server: { ...(s.ihasmail?.server ?? {}), adminUrl: "https://admin.example.com/" } } } as never,
});
await render(account({ id: "k7" }));
expect(host.textContent).not.toContain("Delete account");
const link = host.querySelector('a[href="https://admin.example.com/Management/x:Account/User/k7"]');
expect(link?.textContent).toContain("Open in the console");
}); });
}); });
@@ -108,7 +121,7 @@ describe("an account's tenant", () => {
const render = async (a: DirectoryAccount) => { const render = async (a: DirectoryAccount) => {
const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` }); const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
await act(async () => { await act(async () => {
root.render(<Router hook={hook}><AccountSheet account={a} ctx={tenantCtx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} /></Router>); root.render(<Router hook={hook}><AccountSheet account={a} ctx={tenantCtx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} /></Router>);
}); });
}; };
beforeEach(() => { beforeEach(() => {
@@ -112,7 +112,7 @@ describe("the Administration dashboard", () => {
}); });
}); });
describe("the pointer to INBUXA Admin", () => { describe("the pointer to inbuxa Admin", () => {
let host: HTMLDivElement; let host: HTMLDivElement;
let root: Root; let root: Root;
beforeEach(() => { beforeEach(() => {
@@ -136,7 +136,7 @@ describe("the pointer to INBUXA Admin", () => {
it("names it, and links it where the operator has said where it is", async () => { it("names it, and links it where the operator has said where it is", async () => {
await renderWith("https://admin.example.com"); await renderWith("https://admin.example.com");
const note = host.querySelector(".admin-dashboard-note")!; const note = host.querySelector(".admin-dashboard-note")!;
expect(note.textContent).toContain("INBUXA Admin"); expect(note.textContent).toContain("inbuxa Admin");
const link = note.querySelector("a")!; const link = note.querySelector("a")!;
expect(link.getAttribute("href")).toBe("https://admin.example.com"); expect(link.getAttribute("href")).toBe("https://admin.example.com");
expect(link.getAttribute("rel")).toBe("noopener noreferrer"); expect(link.getAttribute("rel")).toBe("noopener noreferrer");
@@ -144,7 +144,7 @@ describe("the pointer to INBUXA Admin", () => {
it("names it without a link where nobody has", async () => { it("names it without a link where nobody has", async () => {
await renderWith(null); await renderWith(null);
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("INBUXA Admin"); expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("inbuxa Admin");
expect(host.querySelector(".admin-dashboard-note a")).toBeNull(); expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
}); });
}); });
@@ -62,7 +62,7 @@ describe("a tenant's legacy mail protocols switch", () => {
}); });
it("shows who would notice and what it means, then asks for the exact phrase", async () => { it("shows who would notice and what it means, then asks for the exact phrase", async () => {
api.state = { off: false, recent: [{ accountId: "a", name: "[email protected]", protocol: "imap", lastUsedAt: Date.now() - 2 * 86400_000 }] }; api.state = { off: false, partlyOff: [], recent: [{ accountId: "a", name: "[email protected]", protocol: "imap", lastUsedAt: Date.now() - 2 * 86400_000 }] };
signIn(["sysDomainGet", "sysDomainUpdate"]); signIn(["sysDomainGet", "sysDomainUpdate"]);
await render(); await render();
await act(async () => button(host, "Turn off legacy protocols…")!.click()); await act(async () => button(host, "Turn off legacy protocols…")!.click());
@@ -84,7 +84,7 @@ describe("a tenant's legacy mail protocols switch", () => {
}); });
it("turns it back on with one click", async () => { it("turns it back on with one click", async () => {
api.state = { off: true, recent: [] }; api.state = { off: true, partlyOff: [], recent: [] };
signIn(["sysDomainGet", "sysDomainUpdate"]); signIn(["sysDomainGet", "sysDomainUpdate"]);
await render(); await render();
await act(async () => button(host, "Turn legacy protocols back on")!.click()); await act(async () => button(host, "Turn legacy protocols back on")!.click());
@@ -92,7 +92,7 @@ describe("a tenant's legacy mail protocols switch", () => {
}); });
it("shows the state but no switch to someone who can't change domains", async () => { it("shows the state but no switch to someone who can't change domains", async () => {
api.state = { off: true, recent: null }; api.state = { off: true, partlyOff: [], recent: null };
signIn(["sysDomainGet"]); signIn(["sysDomainGet"]);
await render(); await render();
expect(host.textContent).toContain("Off for Acme Corp"); expect(host.textContent).toContain("Off for Acme Corp");
+65 -2
View File
@@ -141,17 +141,45 @@ export function Composer({ draft }: { draft: Draft }) {
await send(key); await send(key);
}; };
// inbuxa: DLP warned: send anyway with a reason the server records
const sendAnyway = async () => {
const reason = await promptDialog({
title: translate("Send anyway?"),
message: translate("Your reason is recorded with the message."),
placeholder: translate("Why this needs to go"),
confirmLabel: translate("Send anyway"),
});
if (!reason?.trim()) return;
patch({ dlp: null, dlpOverride: reason.trim() });
await send(key);
};
const scheduleFor = (at: Date) => { const scheduleFor = (at: Date) => {
sendMenu.close(); sendMenu.close();
setScheduleOpen(false); setScheduleOpen(false);
patch({ sendAt: at.getTime() }); patch({ sendAt: at.getTime() });
}; };
/*
* Switching format converts what has been written, but the quoted message
* is not something this draft wrote: it was prepared in both formats when
* the reply opened. Converting the plain-text quote into HTML would hand
* back a flattened copy of a message that still exists in its original
* markup, so re-attach that instead, and keep only what the author typed
* above it. Where the quote can no longer be found -- edited, or a draft
* that quotes nothing -- convert the whole body as before.
*/
const toggleFormat = () => { const toggleFormat = () => {
// Whichever way the format is changed, the offer has been answered.
if (d.format === "html") { if (d.format === "html") {
patch({ format: "text", text: htmlToText(d.html) }); const at = d.quoteHtml ? d.html.indexOf('<div class="ihm-quote">') : -1;
const written = at >= 0 ? htmlToText(d.html.slice(0, at)) : htmlToText(d.html);
patch({ format: "text", text: at >= 0 ? written.replace(/\s+$/, "") + d.quoteText : written, formatOffer: null });
} else { } else {
patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "<br>") }); const keeps = Boolean(d.quoteText) && d.text.endsWith(d.quoteText);
const written = keeps ? d.text.slice(0, d.text.length - d.quoteText.length) : d.text;
const asHtml = textToHtml(written, { linkify: false, quoteColors: false }).replace(/\n/g, "<br>");
patch({ format: "html", html: keeps ? asHtml + d.quoteHtml : asHtml, formatOffer: null });
} }
}; };
@@ -261,6 +289,41 @@ export function Composer({ draft }: { draft: Draft }) {
)} )}
</div> </div>
</div> </div>
{/*
Replying in one format to a message written in the other loses
something either way: the formatting of a rich reply, or the plain
text somebody chose to write in. The draft opens in the format the
settings ask for, and this offers the other one for this message
only, rather than quietly overriding the setting (#407).
*/}
{d.formatOffer && (
<div className="composer-notice">
<span>{d.formatOffer === "html" ? translate("This message is rich text") : translate("This message is plain text")}</span>
<button type="button" className="btn btn-sm" onClick={toggleFormat}>
{d.formatOffer === "html" ? translate("Switch to rich text") : translate("Switch to plain text")}
</button>
<button type="button" className="icon-btn sm" aria-label={translate("Dismiss")} onClick={() => patch({ formatOffer: null })}><X size={14} /></button>
</div>
)}
{/*
inbuxa: the server's DLP rules refused the last send. A warning
can be answered with a reason; a block can't, only by changing
the message.
*/}
{d.dlp && (
<div className="composer-notice composer-notice-dlp" role="alert">
<AlertTriangle size={14} />
<span title={d.dlp.rules.map((r) => r.notice).join(" ")}>
{d.dlp.kind === "warning" ? translate("Not sent yet:") : translate("Not sent:")} {d.dlp.rules.map((r) => r.notice).join(" ")}
</span>
{d.dlp.kind === "warning" && (
<button type="button" className="btn btn-sm" onClick={() => void sendAnyway()}>
{translate("Send anyway…")}
</button>
)}
<button type="button" className="icon-btn sm" aria-label={translate("Dismiss")} onClick={() => patch({ dlp: null })}><X size={14} /></button>
</div>
)}
{d.format === "html" ? ( {d.format === "html" ? (
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder={translate("Write your message…")} spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} /> <RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder={translate("Write your message…")} spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} />
) : ( ) : (
+194
View File
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { t as translate } from "@/lib/i18n";
/*
* Resizing an image in the composer (Gitea issue #26).
*
* An inserted image went in at its own size, capped at the editor's width,
* and nothing could change it. A screenshot pasted from a large monitor
* filled the message. Click an image and this puts a frame round it, with a
* handle in the corner to drag it to any width, and three fixed sizes plus
* Original size above it.
*
* The frame is drawn over the editor rather than inside it, so none of it
* ends up in the message. The only thing that changes in the message is the
* image's own width: as a `width` attribute, which Outlook needs because it
* ignores CSS on images, and as an inline style, which everything else
* reads. `max-width:100%` stays, so a large image still shrinks to fit a
* phone on the receiving end.
*/
/** Fixed widths, in CSS pixels, as offered in the report. */
export const IMAGE_SIZE_PRESETS: ReadonlyArray<{ label: string; width: number }> = [
{ label: "Small", width: 300 },
{ label: "Medium", width: 600 },
{ label: "Large", width: 900 },
];
const MIN_WIDTH = 24;
/** Set an image's width, or clear it with `null` to go back to its own size. */
export function setImageWidth(img: HTMLImageElement, width: number | null): void {
// A height would fight the new width and stretch the image.
img.removeAttribute("height");
img.style.height = "auto";
img.style.maxWidth = "100%";
if (width === null) {
img.removeAttribute("width");
img.style.width = "";
return;
}
const w = Math.max(MIN_WIDTH, Math.round(width));
img.setAttribute("width", String(w));
img.style.width = `${w}px`;
}
/** The width the image has been given, or null when it is at its own size. */
export function imageWidth(img: HTMLImageElement): number | null {
const w = Number.parseInt(img.getAttribute("width") ?? "", 10);
return Number.isFinite(w) && w > 0 ? w : null;
}
interface Box {
/** Where the editor area sits inside the composer, which is what the layer is positioned against. */
areaTop: number;
areaLeft: number;
areaWidth: number;
areaHeight: number;
/** The image, relative to the editor area's visible box. */
top: number;
left: number;
width: number;
height: number;
}
interface Props {
/** The editable area. Its parent must be positioned, as `.composer-editor` is. */
area: HTMLDivElement;
img: HTMLImageElement;
/** Called once a change is finished, to save the draft. */
onChange: () => void;
onClose: () => void;
}
export function ImageResizer({ area, img, onChange, onClose }: Props) {
const [box, setBox] = useState<Box | null>(null);
const [width, setWidth] = useState<number | null>(() => imageWidth(img));
const layerRef = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; width: number; max: number } | null>(null);
const measure = useCallback(() => {
const a = area.getBoundingClientRect();
const r = img.getBoundingClientRect();
setBox({
areaTop: area.offsetTop, areaLeft: area.offsetLeft, areaWidth: a.width, areaHeight: a.height,
top: r.top - a.top, left: r.left - a.left, width: r.width, height: r.height,
});
}, [area, img]);
useLayoutEffect(() => {
measure();
const ro = new ResizeObserver(measure);
ro.observe(img);
ro.observe(area);
area.addEventListener("scroll", measure);
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
area.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure);
};
}, [area, img, measure]);
// Anything but the image or the frame lets it go, and so does Escape.
useEffect(() => {
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (target === img || layerRef.current?.contains(target)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("keydown", onKey);
};
}, [img, onClose]);
const apply = (w: number | null) => {
setImageWidth(img, w);
setWidth(imageWidth(img));
measure();
onChange();
};
const onHandleDown = (e: ReactPointerEvent<HTMLSpanElement>) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
const style = getComputedStyle(area);
const max = area.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight);
drag.current = { x: e.clientX, width: img.getBoundingClientRect().width, max };
};
const onHandleMove = (e: ReactPointerEvent<HTMLSpanElement>) => {
const d = drag.current;
if (!d) return;
const w = Math.min(d.max, Math.max(MIN_WIDTH, d.width + (e.clientX - d.x)));
setImageWidth(img, w);
measure();
};
const onHandleUp = (e: ReactPointerEvent<HTMLSpanElement>) => {
if (!drag.current) return;
drag.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
setWidth(imageWidth(img));
onChange();
};
if (!box) return null;
// The size bar sits above the image, or inside its top edge when the image
// starts at the top of what is visible.
const barTop = Math.max(4, box.top - 34);
return (
<div
ref={layerRef}
className="img-resize-layer"
style={{ top: box.areaTop, left: box.areaLeft, width: box.areaWidth, height: box.areaHeight }}
>
<div className="img-resize-frame" style={{ top: box.top, left: box.left, width: box.width, height: box.height }}>
<span
className="img-resize-handle"
role="slider"
aria-label={translate("Drag to resize")}
aria-valuenow={Math.round(box.width)}
aria-valuemin={MIN_WIDTH}
title={translate("Drag to resize")}
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onPointerCancel={onHandleUp}
/>
</div>
<div className="img-resize-bar" role="toolbar" aria-label={translate("Image size")} style={{ top: barTop, left: Math.max(4, box.left) }}>
{IMAGE_SIZE_PRESETS.map((p) => (
<button
key={p.width}
type="button"
className="btn btn-sm"
aria-pressed={width === p.width}
onMouseDown={(e) => e.preventDefault()}
onClick={() => apply(p.width)}
>
{translate(p.label)}
</button>
))}
<button type="button" className="btn btn-sm" aria-pressed={width === null} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(null)}>
{translate("Original size")}
</button>
</div>
</div>
);
}
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more