AGPL source offer and name cleanup

Every build writes the exact source it was built from, uncommitted work and
new files included, as source.tar.gz next to the app, named after that tree.
Docker builds, which have no git, pack the build context and name it by a
hash of its files. The sign-in page and Settings > About link to it instead of
a repository that can drift.

What users, operators and packagers see no longer names the upstream server:
- interface text, in all nine catalogues, with a token-session line for
  Security;
- server messages;
- the settings, now MAIL_SERVER_URL, MAIL_SERVERS_FILE, ADMIN_URL and
  MAIL_SERVER_FOLLOW_ADVERTISED_URLS, and mail-servers.example.json;
- the Tenants notice, which is gone;
- the README, CONTRIBUTING and SECURITY.

ihasmail's own FEATURES, KNOWN-ISSUES and ROADMAP stay with public ihasmail,
and INBUXA.md is folded into the README.
This commit is contained in:
2026-09-19 00:13:12 -07:00
parent 9d2de725c9
commit bb25355c23
59 changed files with 444 additions and 2436 deletions
+11 -11
View File
@@ -1,8 +1,8 @@
# ---- ihasmail server configuration ---- # ---- ihasmail server configuration ----
# Base URL of your Stalwart server (scheme + host, no path). ihasmail discovers # Base URL of your mail server (scheme + host, no path). ihasmail discovers
# the JMAP session at <STALWART_URL>/.well-known/jmap. # the JMAP session at <MAIL_SERVER_URL>/.well-known/jmap.
STALWART_URL=https://mail.example.com MAIL_SERVER_URL=https://mail.example.com
# Random secret used to derive encryption keys for persisted sessions. # Random secret used to derive encryption keys for persisted sessions.
# Generate with: openssl rand -base64 48 # Generate with: openssl rand -base64 48
@@ -61,10 +61,10 @@ MAX_UPLOAD_BYTES=52428800
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly. # Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
IMAGE_PROXY=1 IMAGE_PROXY=1
# In-app administration, for accounts whose Stalwart role manages accounts and # In-app administration, for accounts whose role on the mail server manages accounts and
# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses # domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses
# Stalwart's registry methods beyond an account's own password, app passwords # the mail server's registry methods beyond an account's own password, app passwords
# and settings. Stalwart's own admin interface is not affected. # and settings. INBUXA Admin is not affected.
ADMINISTRATION=1 ADMINISTRATION=1
# Branding # Branding
@@ -100,16 +100,16 @@ SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
# Read once at startup: editing a policy means restarting the container. # Read once at startup: editing a policy means restarting the container.
# Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides # Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides
# ---- Several Stalwart servers (optional) ---- # ---- Several mail servers (optional) ----
# #
# Choose the upstream by the domain someone signs in with. STALWART_URL above # Choose the upstream by the domain someone signs in with. MAIL_SERVER_URL above
# stays required and stays the default; this only adds domains that go # stays required and stays the default; this only adds domains that go
# elsewhere. See the shipped stalwart-servers.example.json, and mount it # elsewhere. See the shipped mail-servers.example.json, and mount it
# read-only: # read-only:
# #
# -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro # -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
# #
# STALWART_SERVERS_FILE=/etc/ihasmail/servers.json # MAIL_SERVERS_FILE=/etc/ihasmail/servers.json
# #
# An unlisted domain, or a username with no domain, goes to STALWART_URL. A # An unlisted domain, or a username with no domain, goes to MAIL_SERVER_URL. A
# listed domain never falls back. Read once at startup: editing means a restart. # listed domain never falls back. Read once at startup: editing means a restart.
+22 -23
View File
@@ -1,6 +1,6 @@
# Contributing to ihasmail # Contributing to ihasmail
Thanks for your interest in contributing to **ihasmail** an immutable, JMAP-only webmail client for [Stalwart Mail Server](https://stalw.art/). Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing. Thanks for your interest in contributing to the **INBUXA webmail**, an immutable, JMAP-only webmail client for the INBUXA mail server, built on ihasmail. Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
## Code of Conduct ## Code of Conduct
@@ -9,7 +9,7 @@ By participating in this project, you agree to treat other contributors with res
## Before You Start ## Before You Start
- ihasmail speaks **JMAP only** — it does not support IMAP/POP3/SMTP fallback paths. Keep this in mind when proposing features. - ihasmail speaks **JMAP only** — it does not support IMAP/POP3/SMTP fallback paths. Keep this in mind when proposing features.
- ihasmail has **no database of its own** — all state lives in Stalwart via JMAP. Contributions should not introduce a separate persistence layer without discussion first. - ihasmail has **no database of its own** — all state lives on the mail server, over JMAP. Contributions should not introduce a separate persistence layer without discussion first.
- This project is licensed under **AGPL-3.0**. Any code you contribute will be distributed under this license, including for hosted/SaaS deployments. - This project is licensed under **AGPL-3.0**. Any code you contribute will be distributed under this license, including for hosted/SaaS deployments.
## How to Contribute ## How to Contribute
@@ -21,9 +21,9 @@ Before opening a new issue, please search [existing issues](https://github.com/C
- A clear, descriptive title - A clear, descriptive title
- Steps to reproduce the issue - Steps to reproduce the issue
- Expected behavior vs. actual behavior - Expected behavior vs. actual behavior
- Your environment: browser/OS, Stalwart version, and how ihasmail is deployed (Docker, bare metal, etc.) - Your environment: browser/OS, mail server version, and how ihasmail is deployed (Docker, bare metal, etc.)
- Relevant logs, console errors, or screenshots - Relevant logs, console errors, or screenshots
- Whether the issue is reproducible against a fresh Stalwart instance - Whether the issue is reproducible against a fresh mail server
### Suggesting Features ### Suggesting Features
@@ -41,7 +41,7 @@ For larger changes, please open an issue to discuss the approach **before** subm
2. **Name your branch** descriptively, e.g. `fix/thread-view-scroll` or `feat/search-filters`. 2. **Name your branch** descriptively, e.g. `fix/thread-view-scroll` or `feat/search-filters`.
3. **Keep PRs focused** — one logical change per PR. Large, unrelated changes bundled together are harder to review and more likely to be rejected. 3. **Keep PRs focused** — one logical change per PR. Large, unrelated changes bundled together are harder to review and more likely to be rejected.
4. **Write clear commit messages** describing what changed and why. 4. **Write clear commit messages** describing what changed and why.
5. **Test your changes** against a real (or local) Stalwart instance where possible, since JMAP behavior can be subtle. 5. **Test your changes** against a real (or local) mail server where possible, since JMAP behavior can be subtle.
6. **Update documentation** if your change affects setup, configuration, or user-facing behavior. 6. **Update documentation** if your change affects setup, configuration, or user-facing behavior.
7. **Open the pull request** against `main`, filling out the PR template with: 7. **Open the pull request** against `main`, filling out the PR template with:
- A summary of the change - A summary of the change
@@ -117,7 +117,7 @@ Store tests do not exercise the component. At least one bug in this repo's
history — a shift-click range measured inside a `setState` updater, which React history — a shift-click range measured inside a `setState` updater, which React
runs after the anchor ref has already moved — passed every store assertion and runs after the anchor ref has already moved — passed every store assertion and
failed the moment the built app was driven. If a change is visible on screen, failed the moment the built app was driven. If a change is visible on screen,
run it: `npm run dev:mock` (mock Stalwart, credentials printed on start), then run it: `npm run dev:mock` (the mock mail server, credentials printed on start), then
drive the real thing. Add a component test for what you find; there are drive the real thing. Add a component test for what you find; there are
examples in `web/src/views/*/__tests__/`. examples in `web/src/views/*/__tests__/`.
@@ -128,7 +128,7 @@ examples in `web/src/views/*/__tests__/`.
git clone https://github.com/YOUR-USERNAME/ihasmail.git git clone https://github.com/YOUR-USERNAME/ihasmail.git
cd ihasmail cd ihasmail
``` ```
2. Point your local instance at a running Stalwart Mail Server (a test/dev instance is strongly recommended — do not develop against a production mailbox), or use the built-in mock below. 2. Point your local instance at a running INBUXA mail server (a test/dev instance is strongly recommended — do not develop against a production mailbox), or use the built-in mock below.
3. Install and run, as below. 3. Install and run, as below.
4. Verify your changes don't break existing JMAP calls by exercising core flows: login, list/read mail, send, search, and folder/label operations. 4. Verify your changes don't break existing JMAP calls by exercising core flows: login, list/read mail, send, search, and folder/label operations.
@@ -137,8 +137,8 @@ Requirements: Node ≥ 20.19 (26 recommended), npm ≥ 10.
```bash ```bash
npm install npm install
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173 npm run dev # a real mail server (MAIL_SERVER_URL in .env) — server :8080, Vite :5173
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788 npm run dev:mock # built-in mock mail server ([email protected] / demo), mock on :8788
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
npm run typecheck # tsc for both packages npm run typecheck # tsc for both packages
@@ -153,38 +153,38 @@ build.
#### Architecture #### Architecture
``` ```
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► mail server
React SPA • session cookie ⇄ Basic auth React SPA • session cookie ⇄ Basic auth
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
``` ```
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitizer, search parser, Sieve codec, locale-aware dates, vCard, …). - `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitizer, search parser, Sieve codec, locale-aware dates, vCard, …).
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos. - `server/` — Node/Hono backend: authenticates against the mail server's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake mail server for development and demos.
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `quota`, `blob`, `filenode`, EventSource push, plus the mail server's own
`urn:stalwart:jmap`. Features degrade gracefully when one is missing. registry capability. Features degrade gracefully when one is missing.
#### The mock #### The mock
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against An in-memory fake mail server — enough JMAP to develop and demo against
without a real mailbox. It reproduces the things a naive fake would get wrong, without a real mailbox. It reproduces the things a naive fake would get wrong,
because each cost a live debugging session: `urn:stalwart:jmap` advertised because each cost a live debugging session: the registry capability advertised
**per-account** rather than session-level, identity signatures capped at 2047 **per-account** rather than session-level, identity signatures capped at 2047
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than **bytes**, and `CalendarEvent/set` speaking the server's vocabulary rather than
RFC 8984's. RFC 8984's.
| Switch | What it does | | Switch | What it does |
| --- | --- | | --- | --- |
| `MOCK_NO_FUTURE_RELEASE=1` | Advertises FUTURERELEASE, then drops every hold | | `MOCK_NO_FUTURE_RELEASE=1` | Advertises FUTURERELEASE, then drops every hold |
| `MOCK_NO_REGISTRY=1` | Omits the Stalwart capability, so the sign-in refusal can be tested | | `MOCK_NO_REGISTRY=1` | Omits the registry capability, so the sign-in refusal can be tested |
| `MOCK_NO_SCHEDULING_SEND=1` | Refuses a calendar write that asks for scheduling messages, as for an account without that permission | | `MOCK_NO_SCHEDULING_SEND=1` | Refuses a calendar write that asks for scheduling messages, as for an account without that permission |
| `MOCK_ROLE` | Who the demo user is for Administration: `admin` (the default), `tenant-admin`, `helpdesk` or `user` | | `MOCK_ROLE` | Who the demo user is for Administration: `admin` (the default), `tenant-admin`, `helpdesk` or `user` |
| `MOCK_METRICS=off` | Refuses the dashboard's metric history, as Community does | | `MOCK_METRICS=off` | Refuses the dashboard's metric history, as a server without metrics history does |
| `MOCK_EDITION=enterprise` | Reports Enterprise, which Tenants needs | | `MOCK_EDITION=enterprise` | Reports the `enterprise` edition, for code that still reads it |
It tracks the current Stalwart release rather than 0.16 in general, and each It tracks the current mail server release, and each
behavior is confirmed against a real server before it is copied here — the behavior is confirmed against a real server before it is copied here — the
comments say which version and on what date. Where a release changes something comments say which version and on what date. Where a release changes something
a client can see, the mock changes with it, and the test that pinned the old a client can see, the mock changes with it, and the test that pinned the old
@@ -199,9 +199,8 @@ at build time — nothing writes a version into the tree, and `package.json` sta
at `0.0.0`. `node scripts/version.mjs` prints it for the current checkout. at `0.0.0`. `node scripts/version.mjs` prints it for the current checkout.
The PR number sits after the `+` as build metadata because it records where a The PR number sits after the `+` as build metadata because it records where a
build came from, not how new it is. The version says nothing about Stalwart on build came from, not how new it is. The version says nothing about the mail server on
purpose: what a build needs from the server is stated in the README badge and purpose: the server's own version is its own business. Building an image with the version on it,
in [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Building an image with the version on it,
and the single-host `deploy.example.sh`, are covered in and the single-host `deploy.example.sh`, are covered in
[Installing](https://docs.ihasmail.org/install/). [Installing](https://docs.ihasmail.org/install/).
-1857
View File
File diff suppressed because it is too large Load Diff
-55
View File
@@ -1,55 +0,0 @@
# ihasmail-inbuxa
This is ihasmail for INBUXA's mail server. Public ihasmail stays
Stalwart-facing; everything specific to INBUXA lives here until one product
can serve both. The contract between the two is `docs/spec/contract.md` in
the inbuxa-server repository.
Public ihasmail is the remote `ihasmail`, fetch-only. Merge its `main` in to
keep up. Nothing here is pushed there.
## What's different
- **Sign-in happens on the mail server's own page** (contract C-8, C-10).
ihasmail sends the browser there and gets OAuth tokens back, so it never
handles a password to sign someone in. Two-factor codes are asked for on
that page. Sessions hold sealed tokens and renew them before they expire. A
password change revokes the tokens, so it signs the person out everywhere,
this session included. With one mail server (no `STALWART_SERVERS_FILE`,
or one whose domains all map to `STALWART_URL`), the sign-in page asks for
no address: only whether this is the person's own device, then the server's
page takes it from there. With several servers, the address comes first,
since its domain picks the server.
- **Branded INBUXA.** INBUXA is a product suite, and its webmail carries the
INBUXA name, mark and wordmark, so it can't be taken for public ihasmail,
which stays an independent product. The default `APP_NAME` is `INBUXA`; the
sign-in page, header, page title, installed-app name and About page say
INBUXA. ihasmail's version and AGPL source line stay as its credit. Set
`APP_NAME` to something else and that name shows as text, as in public
ihasmail.
- **Tenants are offered on every server**, whatever edition it reports.
`SHOW_ENTERPRISE_NOTICES` still adds the notice for an upstream Stalwart.
## Configuration
**Before any deployment, set `SOURCE_URL`** to where this fork's source is
published. The AGPL's offer has to point at the source of the code that's
running, and the default still points at public ihasmail.
Server sign-in is on when `OAUTH_CLIENT_SECRET` is set. Without it,
ihasmail-inbuxa keeps public ihasmail's password form.
| Variable | Meaning |
|---|---|
| `OAUTH_CLIENT_SECRET` | The secret of the confidential client the mail server registers for this webmail. On INBUXA, the same value as the server's `INBUXA_WEBMAIL_CLIENT_SECRET`. |
| `OAUTH_CLIENT_ID` | The client's id. Default `ihasmail-inbuxa`, which is what INBUXA registers. |
| `PUBLIC_URL` | Where browsers reach ihasmail, without `BASE_PATH`. Required with `OAUTH_CLIENT_SECRET`. The redirect URI is `PUBLIC_URL` + `BASE_PATH` + `/api/auth/callback`, and must match the server's `INBUXA_WEBMAIL_URL` + `/api/auth/callback` exactly. |
On the INBUXA server, set `INBUXA_WEBMAIL_URL` to ihasmail's address (with
`BASE_PATH`, if any) and `INBUXA_WEBMAIL_CLIENT_SECRET` to the shared secret.
The server registers the client on start and allows ihasmail's origin for
cross-origin requests.
For local development, `npm run dev:mock` works as before. The mock also
answers OAuth: start it and ihasmail with `OAUTH_CLIENT_SECRET=mock-oauth-secret`
and a `PUBLIC_URL`, and its sign-in page approves the demo user at once.
-158
View File
@@ -1,158 +0,0 @@
# Known issues and pending QA
What was checked, against which server, and when. For a failure you are hitting
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
The live instance runs **0.16.22**, and as of **2026-08-26 there is nothing
left pending**. Most entries below were exercised against 0.16.19 on the date
they name, and the dates still say so: each upgrade since was read against the
diff rather than re-run, and nothing in those diffs touches the session
capabilities, blob, quota, submission or registry paths these entries describe.
The calendar entries carrying a 2026-08-31 date were exercised against a live
0.16.20 directly, as were the public-key entries dated 2026-09-05.
**0.16.21 was different and was re-run rather than read.** It changed four
things a client can see, one of which resolved an entry below outright: an
occurrence of a recurring event is identified by its recurrence id rather than
its position in the series, so an id held across a write no longer names a
different date; `Calendar/get` and `AddressBook/get` return every property when
none are named; EventSource advertises its ping interval in seconds rather than
milliseconds; and a calendar write that asks for scheduling messages is refused
when the account may not send them. The mock reproduces all four. The app
was run against a real 0.16.21 with mail, calendar and contacts exercised by
hand, including editing one occurrence of a recurring series through the
interface and confirming the rest of the series stayed where it was.
**0.16.22 (2026-09-13) was tested too.** The app has been tested against it on
the live instance. Its changes a client can see are all in `CalendarEvent/get`
and `ContactCard/get`, and were read from its source before the mock was made
to follow them: `baseEventId` is `null` for an event read by its stored id,
`recurrenceRule` and `recurrenceOverrides` asked for on a synthetic id come back
`null`, `useDefaultAlerts` belongs to the reader and reads `false` until set,
and an empty `properties` list returns `id` alone. None of them contradicts an
entry below.
What remains here is not a list of unknowns but of things worth knowing — where
Stalwart departs from a spec, where a setting has to be turned on for a feature
to work, and what ihasmail deliberately does not do.
Entries keep saying what was checked and when, because this section has been
wrong before: the 0.16 registry path was once recorded as verified live when a
capability looked for in the wrong place meant it had never run at all.
Some entries record what a live **0.15.5** proved before that server was
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
rather than about 0.15 — a byte cap that still applies, a flow that still
works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)).
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
- **The Basic credential ihasmail proxies with reaches the admin `x:` methods**, as it already reached the self-service ones. No separate token is involved.
- **An account reads back in the shapes the code expects**: `credentials` as `{"0": {"@type": "Password", …}}`, aliases and group memberships as objects, the disk limit under `quotas.maxDiskQuota`.
- **A new domain gets automatic DKIM straight away** — an Ed25519 and an RSA key, both `active`, with their records already in the zone file — and manual DNS and certificates.
- **`dnsZoneFile` is BIND text**, one record per line as `name IN TYPE value`, with a long TXT record split into a parenthesized run of quoted chunks. A throwaway domain's file held 18 records and 3 continuation lines; every record parsed and the panel showed 18 rows. The production domains also carry TLSA records, which show as rows like any other.
- **`x:DkimSignature/query` accepts a `domainId` filter.**
- **`catchAllAddress` wants a whole address.** A bare local part is refused with `invalidPatch`, *"Invalid email address"*.
- **A domain its keys still name cannot be destroyed**: `objectIsLinked`, with `linkedObjects` listing each as `{"object": "DkimSignature", "id": …}` and no description. Removing through the panel destroys the keys first and then the domain; both were gone afterwards.
- **A reserved TLD is refused**: `example` as a domain's top level comes back `invalidPatch`, *"Invalid domain name"*, naming `name`.
The last two were then tried by hand on the live server the same day and behaved as described. **A password set by an administrator** — written to the account's existing credential, `credentials/<index>/secret` — signs in. **The outranking guard** held: an account with more rights than the viewer's role opens read-only. The guard exists because the source shows Stalwart skipping its grant check when only a password changes and on a delete, and it stays for that reason.
- **The dashboard's feeds were settled on the live server before the code was written (2026-09-15, 0.16.22 Enterprise, read-only calls from an administrator's session).** The first probes guessed two of these wrong — filtering on `timestamp`, and counting received mail from `message-ingest.*` — and the server and the 0.16.22 source agreed on the answers below:
- **The metric history filters on comparison names.** `x:Metric/query` accepts `{"timestampIsGreaterThanOrEqual": …, "metric": [names]}`; a bare `timestamp`, `after` or `metric` as a string is `unsupportedFilter`. Sorting on `timestamp` works. At the default interval a day is about 80 records for the six metrics the dashboard reads, and a get takes at most 500.
- **Received and sent are `queue.*` counters**, not `message-ingest.*`: `queue.message-queued` for received, and `queue.authenticated-message-queued` + `queue.dsn-queued` + `queue.report-queued` for sent, which is what Stalwart's own dashboard adds up. A Counter holds its interval's count and a zero one is not written; the `*-time` histograms are cumulative, which is why nothing reads them.
- **Memory is the `server.memory` Gauge**, in bytes, one per interval. **Counts** come from `/query` with `calculateTotal: true` and `limit: 0`, which returned the whole total for `x:Account` (users only, via `@type`), `x:Domain` and `x:QueuedMessage`.
- **`x:Metrics/get` is not the history.** It is the singleton holding the collection settings (Prometheus and OpenTelemetry export, the metrics policy); the history is `x:Metric`.
**Not confirmed live:** that a tenant administrator's counts are scoped to the tenancy, and that a Community server refuses `x:Metric` as `forbidden`. Both are read from the 0.16.22 source (`query.rs`, `queued_message.rs`, `registry/mod.rs`); the production server has no tenants and is Enterprise, so neither could be tried there without writing. The dashboard's handling of both is covered by tests against the refusal Stalwart's source gives.
- **Groups were built from the 0.16.22 source and a mock, then confirmed on the live server (2026-09-15)** with a throwaway group on one of the server's domains, created and removed, its only member the administrator's own account:
- **A group is created** as `x:Account` with `@type: "Group"`, no credentials and no encryption setting, and reads back with roles `{"@type": "Default"}`, `permissions` `Inherit`, a `locale` of `en-US` and `usedDiskQuota` 0.
- **Membership is the member's.** `"memberGroupIds/<group>": true` on the user was accepted; `{"@type": "User", "memberGroupIds": <group>}` then found them with a total of 1, and the user's own `memberGroupIds` read `{"<group>": true}`. The same pointer with `null` took them out again and left the set as it was before.
- **A group with members cannot be deleted**: `objectIsLinked`, with `objectId` as `{"object": "Account", "id": <group>}` and `linkedObjects` listing each member as `{"object": "Account", "id": …}`. With the member out, the delete went through and the group read back as `notFound`.
The same run tried a throwaway mailing list before the Mailing lists section was written. It was created with `recipients` as a set, `{"[email protected]": true}`, and read back as `name`, `domainId`, `description`, `aliases`, `memberTenantId`, `recipients` and a computed `emailAddress`. `"recipients/<address>": true` added one and left the other; a `text` filter found it; it was destroyed with nothing linked. Not tried: removing a recipient with `null` (the same set patch as a group membership, which was), and how the server words a recipient that is not an address.
Still from source only: that membership gives a member no permissions (`access_token.rs` builds a user's permissions from their own roles), and that groups cannot nest.
- **Roles were built from the 0.16.22 source, its schema and the mock, then confirmed on the live server (2026-09-15)** with throwaway `ihasmail-role-test` roles, created and removed:
- **A role is created** with `description`, `roleIds`, `enabledPermissions` and `disabledPermissions` as sets, and reads back with them and `memberTenantId`.
- **Pointers change one entry each**: `enabledPermissions/<p>` and `disabledPermissions/<p>` with `true` or `null`, `roleIds/<id>` likewise, and `description` in the same update, all applied together.
- **A name that is not a permission fails the whole update** as `invalidPatch`, *"Invalid value for object property"*, naming the pointer — which is how a probe using the mock's made-up `jmapEmailSet` found that the mock had carried a permission Stalwart does not have since Accounts was built; it is `jmapEmailUpdate` now, and the mock refuses unknown names.
- **A grant the caller does not hold is refused**: `forbidden`, *"You are not authorized to grant permissions: scimAccess"*.
- **A role another role builds on cannot be deleted**: `objectIsLinked`, `objectId` `{"object": "Role", …}`, `linkedObjects` naming the child.
- **The defaults** read from `x:Authentication`: users get User; groups get Group; tenant administrators get Tenant Administrator and User; administrators get System Administrator and User.
**The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs toward refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` through ihasmail's server was confirmed on production after the deploy (2026-09-15, v2026.9.15+pr364)**: `/api/admin/permissions` answered 200 with all 661 permissions, the same list as the 0.16.22 snapshot, and the Roles picker drew them under 60 headings. The four bootstrap roles grant 244 (User), 229 (Group), 50 (Tenant Administrator) and 452 (System Administrator) once their trees are followed.
- **Tenants were built from the 0.16.22 source, its schema and the mock, then tried on the live server (2026-09-15)** with throwaway `ihasmail-tenant-test` tenants, a throwaway role, two throwaway lists and a throwaway domain, all removed. The live run changed the design twice:
- **A tenant is created and edited as built**: `name`, `logo`, `roles`, `permissions`, `quotas`; `quotas/<name>` pointers, a logo and a rename in one update; an unknown quota name is `invalidPatch`.
- **Something in a tenant has to be on a domain in that tenant.** A list in the tenant on a domain in none was refused, `invalidForeignKey` with `objectId` `{"object": "Domain", …}`; the same list on a domain created in the tenant was accepted — and so was a list in *no* tenant on that domain. **So an account's tenant choice offers only its domain's tenant**, and a new account starts in the tenant of the domain it is made on.
- **A domain created in a tenant puts its DKIM keys in the tenant too**, and they stay there. They count against `maxDkimKeys` and keep the tenant from being deleted, so they are counted with everything else.
- **Stalwart lets a domain leave a tenant while the tenant still has things on it**, leaving them in a tenant on a domain outside it. **The panel refuses to take a domain out while any of the tenant's accounts are on it.** Mailing lists cannot be filtered by domain, so a list is not checked.
- **A tenant still holding anything is kept**: `objectIsLinked`, `objectId` `{"object": "Tenant", …}`, `linkedObjects` naming a role, a list and DKIM keys. A role set to `memberTenantId: null` left it, after which the tenant was deleted.
Still from source only: that only a caller outside every tenant may set `memberTenantId` (`set.rs` passes `can_set_tenant` only when the token has no tenant), and that a tenant administrator's queries are scoped to the tenant. On a server that does not report Enterprise the Tenants page is only its notice.
- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalog's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart.
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognized by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
- **Administration is off for a device not marked as your own, and for an installation that says so.** Both are enforced by the server rather than hidden by the menu: such a session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. That is worth stating because the proxy otherwise forwards whatever the browser sends, and before these gates an administrator's console could make any registry call their role allowed. For a session that may not administer, the proxy reads a request body only when it could name a registry method — a `"x:` in the text, or a `\u` escape that could spell one — so ordinary mail traffic is forwarded untouched.
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localize and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogs and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalog key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalog can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalog key, although no component here runs its props through `t()`, so 19 strings with translations in every catalog (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogs. English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``, was the last gap. It can't be a catalog key as written. Since 2026-09-14 the check flags any template literal in one of these positions that has words between its values, and the twelve that existed are now keys with placeholders. They were the quota bar, the address menu, a folder's unread count, the recipient chips, the contact editor's title, shared calendars and address books, the date and time fields, the attachment fallback name, and the free/busy bar. That bar showed the raw JMAP value (`confirmed`) in every language.
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock omitted it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server. **0.16.21 fixed this for calendars and address books**: with `properties` omitted, `Calendar/get` and `AddressBook/get` now return every property, `shareWith` included — **confirmed live on 0.16.21 (2026-09-06)**. `Mailbox/get` on the same server still leaves it out, so the mock now hides it for mail folders alone, and ihasmail keeps naming the property everywhere.
- **Stalwart's `x:PublicKey` registry works, and ihasmail deliberately does not expose it.** A Settings section for it has been built twice — [PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), closed 2026-08-26, and [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285) — and withdrawn both times, for a reason that has nothing to do with the server: **nothing in ihasmail signs, encrypts, decrypts or verifies with a key**, so a page for managing them is furniture rather than a feature. It ends up telling the reader, in its own footnote, that adding a key does nothing. The registry is written up here rather than in [ROADMAP.md](ROADMAP.md) because what follows is established fact about Stalwart that cost a live probe, and losing it twice to a closed pull request was how the second attempt came to exist at all. Everything below was **confirmed live on 0.16.20 (2026-09-05)** from a normal account with no administrative rights, and the full round trip — create, read back, rename, patch, destroy — succeeded for both formats.
- **An ordinary user may read *and* write their own keys**, whatever the permissions table says: Stalwart documents every `sysPublicKey*` permission as administrative, and the server granted them anyway. A create carrying a malformed key was refused with `invalidProperties` naming `key` rather than `forbidden` — a rejection of the key, not of the person. Had the documentation been right, any such feature would have been useless to everybody but an administrator, which is why this was probed first.
- **It takes S/MIME certificates as well as OpenPGP keys, and parses both.** A self-signed X.509 certificate carrying `emailProtection` and an `email:` SAN registered, read back and destroyed cleanly, and a malformed one is refused by a decoder of its own: *"Failed to decode X509 certificate: BER decoding error: Expected Tag { class: Universal, value: 16 } tag…"*. Worth checking rather than assuming, because every *other* message the registry returns names OpenPGP — including for input that is not OpenPGP at all — so the server reads as though OpenPGP were the only format it knows. It is not.
- **A key can parse perfectly and still be refused, and says something different when it is.** A sign-and-certify OpenPGP key with no encryption subkey — which is what `gpg --quick-generate-key` produces — comes back *"Could not find any suitable keys in OpenPGP public key"*, distinct from the parser's *"Failed to decode OpenPGP public key: Malformed packet: Malformed CTB…"*. Any client showing these must keep them apart: one says paste it again, the other says the key needs an encryption subkey and no amount of care with the clipboard will help. Certificates have no equivalent trap, since one issued for email use has key encipherment by construction.
- **`emailAddresses` comes back as `{}` when empty** — an object, where a JMAP list property should be an array. Nothing fails loudly: it is a plain `Get` response that type-checks against a hand-written interface and then throws in `join()` while a list renders. A client must check the shape rather than trust the type.
- **A create answers with the id alone**, no `createdAt`, so anything that reads the date back out of the create response gets `undefined`. **Patching `key` on an existing entry is allowed**, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keeps `createdAt` meaning what it says.
- **`expiresAt` is the registry's own field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
- **Signature checking is done here, and its trust model is deliberately small.** Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does *not* do, because the gap is a design choice rather than an omission. **No chain of trust is validated** — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is gray rather than green, and why a changed signer never overwrites the pin. Verified against real `openssl smime -sign` output rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be.
- **OpenPGP signatures cannot be checked at all, for a reason that is not effort.** A PGP signature carries no key, so verifying one needs the sender's public key in advance, and there is nowhere to get it: `x:PublicKey` holds the *account's own* keys, not correspondents'. Fetching from a keyserver or via WKD would tell a third party who you correspond with each time you opened a message — the same leak the image proxy exists to close — so it is not done. Such a message says so by name rather than failing as an unknown format, and it says *could not check* rather than *did not check out*, which is a distinction worth keeping: one is ignorance and the other is an accusation.
- **Two signature shapes are declined rather than attempted.** SHA-1 signatures are refused outright — one nobody can forge in practice today is still not one to put a tick beside. RSA-PSS is declined because the salt length lives in parameters ihasmail does not read, and guessing wrong would report a perfectly good signature as *bad*, which is a far worse thing to say than "cannot check". Both are shown as uncheckable, not as broken.
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognize ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honors a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, canceled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a canceled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Canceling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. Since 0.16.22 the same event read by its *stored* id answers `baseEventId: null` rather than its own id, which changes nothing here: a one-off read through the synthetic id an expanded query gave it still carries a base.
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email`**no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behavior and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
- **A synthetic id was only true until the next write, through 0.16.20. Fixed in 0.16.21.** Stalwart's expanded-occurrence ids used to encode a position in the series, so writing a `recurrenceOverrides` entry renumbered them. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier, so an id cached across a write silently pointed at another date and a delete meant for one occurrence removed a different one. The failure was never a `notFound` a client would notice; it was a confident answer about the wrong day. **0.16.21 identifies an occurrence by its recurrence id, and confirming that was the point of re-running rather than reading the diff. Confirmed live on 0.16.21 (2026-09-06)**: the same shape of test — five weekly occurrences expanded, the third retitled through its own synthetic id, all five original ids re-read — left every id on its own date, with none renumbered and none `notFound`. A second override written through the interface behaved the same way. The defense stays regardless: ihasmail still never mutates an occurrence by an id it is holding, and `updateEvent` and `destroyEvent` still re-resolve by `recurrenceId` immediately before acting, because a date can still leave a series and because the client supports 0.16 as a whole rather than only its newest release. The mock follows the new behavior, and the test that pinned the old renumbering now pins the stability instead — rewritten rather than deleted, so the reversal stays on the record.
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
- **Recurring events can be edited and deleted one date at a time, since 0.16.20.** A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a `recurrenceOverrides` entry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. **Confirmed live on 0.16.20 (2026-08-31)** end to end against a five-week series: a legal patch landed on the override with `start` and `duration` filled in by the server; `useDefaultAlerts` was refused with *"This property cannot be modified on a single occurrence."*; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with *"A base event and its instances cannot be modified in the same request."* The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the *series'* start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up.
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
+63 -74
View File
@@ -1,114 +1,103 @@
<p align="center"> <p align="center">
<img src="web/public/img/logo.png" alt="ihasmail" width="150"> <img src="web/public/img/inbuxa-mark.png" alt="" width="110">
</p> </p>
<p align="center"> <h1 align="center">INBUXA webmail</h1>
<strong><a href="https://demo.ihasmail.com">Try the demo</a></strong><br>
<sub>A working copy with an invented mailbox behind it — no sign-up, nothing real, nothing kept.</sub>
</p>
<p align="center"> <p align="center">
<a href="LICENSE"><img alt="License: AGPL-3.0-or-later" src="https://img.shields.io/badge/license-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a> <a href="LICENSE"><img alt="License: AGPL-3.0-or-later" src="https://img.shields.io/badge/license-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.22" src="https://img.shields.io/badge/Stalwart-0.16.22-6366f1?style=flat-square"></a>
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
</p> </p>
# ihasmail The webmail of the INBUXA suite: mail, calendars, contacts, files and filters
in one app that works as well on a phone as on a desktop. It talks only JMAP to
the INBUXA mail server, and keeps nothing of its own: everything durable,
settings included, lives on the server, so the container is disposable.
**Immutable webmail for [Stalwart Mail Server](https://stalw.art).** Mail, > **Status: in development, not released.**
calendars, contacts, files and filters in one app that works as well on a phone
as on a desktop — and a container with nothing to persist.
ihasmail talks only JMAP to Stalwart. There is no database, no IMAP or SMTP,
and with `IMMUTABLE=1` no writable filesystem either: everything durable,
settings included, belongs to Stalwart, so the container is disposable.
| | |
| --- | --- |
| 🌐 **[ihasmail.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
| 📋 **[FEATURES.md](FEATURES.md)** | Everything it does, feature by feature, with the capability each one needs |
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
## Screenshots
| | |
| --- | --- |
| **Inbox & conversation (dark)** ![Inbox, dark theme](screenshots/inbox-dark.jpg) | **Inbox & conversation (light)** ![Inbox, light theme](screenshots/inbox-light.jpg) |
| **Composer** ![Composer](screenshots/compose.jpg) | **Calendar** ![Calendar](screenshots/calendar.jpg) |
| **Contacts** ![Contacts](screenshots/contacts.jpg) | **Sieve filter builder** ![Filters](screenshots/filters.jpg) |
Taken against the built-in mock with sample data. More, including the phone
layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
## What's in it ## What's in it
- **Mail** conversations, labels, search operators, keyboard shortcuts, scheduled and undo send, invitations and RSVP, filters made from a message - **Mail:** conversations, labels, search operators, keyboard shortcuts,
- **Calendar** — month, week, day and agenda views, recurrence, attendees and free-busy scheduled and undo send, invitations and RSVP, filters made from a message.
- **Contacts** — address books, groups, vCard import and export - **Calendar:** month, week, day and agenda views, recurrence, attendees and
- **Files** — browse, upload, move, share free-busy.
- **Signature checking** — S/MIME signed mail verified as you read it - **Contacts:** address books, groups, vCard import and export.
- **Settings that follow the account**, kept in the account's own storage on Stalwart - **Files:** browse, upload, move, share.
- **On a phone** — swipe to archive or delete, pull to refresh, hold to select - **Signature checking:** S/MIME signed mail verified as you read it.
- **Administration** — a dashboard, accounts, groups, mailing lists, roles, tenants and domains, each shown only when the Stalwart role allows it - **Settings that follow the account**, stored on the mail server.
- **Ten interface languages and twelve themes** — the nine translations are marked Beta until a native speaker has read them - **On a phone:** swipe to archive or delete, pull to refresh, hold to select.
- **Platform** — installable PWA, Web Push, `mailto:` handler, no credentials in the browser, strict CSP - **Administration:** a dashboard, accounts, groups, mailing lists, roles,
tenants and domains, each shown only to an account whose role allows it.
Everything else is in INBUXA Admin.
- **Sign-in on the mail server's own page**, two-factor included. The webmail
never handles a password to sign someone in, and holds only sealed tokens.
- **Ten interface languages and twelve themes.**
The long version is [FEATURES.md](FEATURES.md) and ## Configuration
[ihasmail.org](https://ihasmail.org/#features).
## Requirements | Variable | Meaning |
|---|---|
| `MAIL_SERVER_URL` | How this webmail reaches the mail server. |
| `APP_SECRET` | A long random secret for sealing sessions. Required in production. |
| `OAUTH_CLIENT_SECRET` | Turns on sign-in through the server's page. The secret of the confidential client the server registers for this webmail: on the server, the same value as `INBUXA_WEBMAIL_CLIENT_SECRET`. |
| `OAUTH_CLIENT_ID` | The client's id. Default `ihasmail-inbuxa`, which is what the server registers. |
| `PUBLIC_URL` | Where browsers reach the webmail, without `BASE_PATH`. Required with `OAUTH_CLIENT_SECRET`. The redirect URI, `PUBLIC_URL` + `BASE_PATH` + `/api/auth/callback`, must match the server's `INBUXA_WEBMAIL_URL` + `/api/auth/callback` exactly. |
| `MAIL_SERVERS_FILE` | Optional: several mail servers, picked by the account's domain. See `mail-servers.example.json`. |
| `ADMIN_URL` | Optional: where INBUXA Admin is, for the dashboard's link. |
| `APP_NAME` | What the webmail calls itself. Default `INBUXA`, shown as the INBUXA wordmark; any other name shows as text. |
**Stalwart 0.16 or newer** — sign-in refuses anything older, by name. Tested `.env.example` lists the rest.
against 0.16.22; what changed in each release is in
[KNOWN-ISSUES.md](KNOWN-ISSUES.md).
- **No Stalwart yet?** [ihasmail-oneshot](https://github.com/Coffey-Labs/ihasmail-oneshot) deploys a new Stalwart and ihasmail together on one host, in one command. On the mail server, set `INBUXA_WEBMAIL_URL` to the webmail's address (with
- **On Stalwart 0.15?** [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) upgrades it in place, or stay on the [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support) release. `BASE_PATH`, if any) and `INBUXA_WEBMAIL_CLIENT_SECRET` to the shared secret.
The server registers the client on start and allows the webmail's origin for
cross-origin requests.
With one mail server, the sign-in page asks for no address, only whether this
is the person's own device. The server's page asks for the rest. With several,
the address comes first, since its domain picks the server.
A password change revokes the server's tokens, so it signs the person out
everywhere, this session included.
## Quick start (Docker) ## Quick start (Docker)
```bash ```bash
cp .env.example .env cp .env.example .env
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48) # edit: MAIL_SERVER_URL, APP_SECRET, and for server sign-in OAUTH_CLIENT_SECRET and PUBLIC_URL
docker compose up --build -d docker compose up --build -d
# → http://localhost:8080 — put a reverse proxy in front for TLS # → http://localhost:8080. Put a reverse proxy in front for TLS.
``` ```
Or pull the published image, `ghcr.io/coffey-labs/ihasmail`. Releases are ## Source code
weekly, so it is usually a few days behind `main`.
People sign in with their Stalwart mailbox credentials. **An account with Every build carries its own source. The sign-in page and Settings About
two-factor authentication needs an app password**, created in Stalwart's own link to `source.tar.gz`, the exact tree the running version was built from,
settings. uncommitted work included. It's written next to the app at build time and
named after that tree.
Everything else — TLS, running immutably, several Stalwart servers, settings
the installation decides, every environment variable — is in
[Installing](https://docs.ihasmail.org/install/) and
[Configuring](https://docs.ihasmail.org/configure/).
## Development ## Development
```bash ```bash
npm install npm install
npm run dev:mock # built-in mock Stalwart ([email protected] / demo) npm run dev:mock # the built-in mock mail server ([email protected] / demo)
npm test npm test
``` ```
The mock also answers OAuth. Start it and the webmail with
`OAUTH_CLIENT_SECRET=mock-oauth-secret` and a `PUBLIC_URL`, and its sign-in
page approves the demo user at once.
Architecture, the mock's switches and how versions are numbered are in Architecture, the mock's switches and how versions are numbered are in
[CONTRIBUTING.md](CONTRIBUTING.md#development-setup). [CONTRIBUTING.md](CONTRIBUTING.md#development-setup).
## Contributing ## Built on ihasmail
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) · The INBUXA webmail is built on [ihasmail](https://github.com/Coffey-Labs/ihasmail),
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately. Coffey Labs' own webmail, which stays an independent product. The public
repository is the remote `ihasmail`, fetch-only, and its `main` is merged in to
keep up. Nothing here is pushed there.
## License ## License
Copyright (C) 2026 Coffey Labs AGPL-3.0-or-later. See [LICENSE](LICENSE). Copyright (C) 2026 Coffey Labs. AGPL-3.0-or-later; see [LICENSE](LICENSE).
If you run a modified ihasmail, set `SOURCE_URL` to your own repository: the
sign-in page and Settings About both show it. See
[Rebranding](https://docs.ihasmail.org/rebranding/).
-33
View File
@@ -1,33 +0,0 @@
# Roadmap / not yet
Things ihasmail does not do, and why. An issue number here says where the entry
came from, not that it is tracked elsewhere — a report can be closed because the
bug in it was fixed while the larger thing it asked for stays on this page. What
is genuinely open lives in [the issue tracker](https://github.com/Coffey-Labs/ihasmail/issues);
the rest is here because the answer is "no", not "not yet".
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
- **More of Stalwart's directory in Administration.** The Administration menu opens on a dashboard and manages accounts, groups, mailing lists, tenants, roles and domains today — see [FEATURES.md](FEATURES.md#administration). DNS and ACME providers are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalog without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
- **Signing and encrypting mail.** *Reading* a signature is built: S/MIME signed mail is checked as it is read, and the signer is remembered so a change is called out — see [Checking a signature](FEATURES.md#checking-a-signature). What is not built is anything that produces a signature or touches ciphertext, and the reason is not Stalwart. This is client work over the message body: JMAP hands over the MIME blob and the rest is ours.
The blocker is a security model, not code, and it is the same one it has always been. Signing and decrypting need a **private** key in a page served by the same host that would handle it, which runs straight into two things ihasmail says about itself: that it never stores a credential, and that it runs immutably with nowhere to keep one. Verifying needed none of that — the certificate travels inside the message — which is exactly why it could be built first and why it went first.
**OpenPGP signatures are not checked, and this is a harder problem than it looks.** A PGP signature does not carry the key, so verifying one means having the sender's public key already. ihasmail has no source for it: `x:PublicKey` is the account's *own* registry, and fetching from a keyserver or WKD would tell a third party who you correspond with, which is precisely the leak the image proxy exists to close. A local store of correspondents' keys is possible and is not a small feature; nobody has asked for it yet.
*Managing* keys — publishing your own to `x:PublicKey` — has been built twice ([PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285)) and withdrawn twice, because a Settings page for keys nothing uses is furniture. That reasoning is now partly spent: something does use a key. But what signature checking uses is the certificate inside the message, not anything in the registry, so publishing your own key remains a feature waiting for a consumer.
**Encryption at rest is refused rather than deferred.** Stalwart offers it as `encryptionAtRest`, a field on `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object whatever the docs suggest, and its value is a typed object (`{"@type": "Disabled"}`) rather than a bare string. It is self-service, needs no administrator, and would be easy to offer. It will not be: turning it *off does not decrypt what is already there*. Every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key, so switching it on is a one-way door — and a toggle that reads as "make my mail safer" while quietly being irreversible is the wrong thing to hand an ordinary user.
**Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponized in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
So if one of the two gets built here it is S/MIME, because it is the one that is *more* deployed in the places that pay for software: native in Outlook and Apple Mail, and routine in defense, healthcare, finance and government, where a CA issues and revokes certificates that an IT department can actually administer. The web of trust never became something anybody could run at scale.
Expect the asking to be far out of proportion to the using. A self-hosted webmail for Stalwart draws self-hosters, privacy-minded users and European SMEs, which is about the densest concentration of PGP users left alive — so this will be requested much more often than it would be used, and that is an argument for keeping it here, described honestly, rather than either building it on the strength of the requests or refusing it outright.
+5 -5
View File
@@ -22,13 +22,13 @@ Please include as much of the following as you can:
- A description of the vulnerability and its potential impact - A description of the vulnerability and its potential impact
- Steps to reproduce, or a proof-of-concept - Steps to reproduce, or a proof-of-concept
- The version/commit of ihasmail affected - The version/commit of ihasmail affected
- The version of Stalwart Mail Server you were testing against, if relevant - The version of the mail server you were testing against, if relevant
- Whether the issue is in ihasmail itself, in how it talks to Stalwart over JMAP, or in a dependency - Whether the issue is in the webmail itself, in how it talks to the mail server over JMAP, or in a dependency
### What to Expect ### What to Expect
- **Acknowledgment:** You should receive a response within a few days confirming the report was received. - **Acknowledgment:** You should receive a response within a few days confirming the report was received.
- **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on Stalwart's store over JMAP, some reports may need to be routed to or coordinated with the [Stalwart Mail Server](https://github.com/stalwartlabs/mail-server) project if the root cause lives there rather than in ihasmail's client code. - **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on the mail server's store over JMAP, some reports may need to be routed to or coordinated with the mail server's own project if the root cause lives there rather than in ihasmail's client code.
- **Fix & disclosure:** Once a fix is ready, a new release will be published. We'll coordinate with you on public disclosure timing and credit, if you'd like to be credited. - **Fix & disclosure:** Once a fix is ready, a new release will be published. We'll coordinate with you on public disclosure timing and credit, if you'd like to be credited.
### Scope ### Scope
@@ -42,9 +42,9 @@ In scope:
Out of scope (please report upstream instead): Out of scope (please report upstream instead):
- Vulnerabilities in Stalwart Mail Server itself report those to the [Stalwart project](https://github.com/stalwartlabs/mail-server) - Vulnerabilities in the INBUXA mail server itself: report those to the mail server's own project
- Vulnerabilities in third-party libraries with no demonstrated impact on ihasmail - Vulnerabilities in third-party libraries with no demonstrated impact on ihasmail
- Issues requiring physical access to a user's device or an already-compromised Stalwart instance - Issues requiring physical access to a user's device or an already-compromised mail server
## Disclosure Policy ## Disclosure Policy
+1 -1
View File
@@ -25,7 +25,7 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
environment: environment:
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env} MAIL_SERVER_URL: ${MAIL_SERVER_URL:?set MAIL_SERVER_URL in .env}
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)} APP_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:-}
@@ -1,15 +1,15 @@
{ {
"_comment": [ "_comment": [
"Optional: which Stalwart a domain signs in to.", "Optional: which mail server a domain signs in to.",
"", "",
"STALWART_URL stays required and stays the default. This file only adds", "MAIL_SERVER_URL stays required and stays the default. This file only adds",
"domains that go somewhere else -- delete it and nothing changes.", "domains that go somewhere else -- delete it and nothing changes.",
"", "",
"Point at it with STALWART_SERVERS_FILE=/etc/ihasmail/servers.json and mount", "Point at it with MAIL_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
"it read-only. Read once at startup, so editing it means restarting.", "it read-only. Read once at startup, so editing it means restarting.",
"", "",
"A domain that is not listed here, and a bare username with no domain at", "A domain that is not listed here, and a bare username with no domain at",
"all, go to STALWART_URL. A domain that IS listed never falls back: if its", "all, go to MAIL_SERVER_URL. A domain that IS listed never falls back: if its",
"server is unreachable that sign-in fails, because falling back would", "server is unreachable that sign-in fails, because falling back would",
"authenticate somebody against a server their domain was routed away from.", "authenticate somebody against a server their domain was routed away from.",
"", "",
@@ -20,10 +20,10 @@
"ihasmail's Administration dashboard links to each server's own", "ihasmail's Administration dashboard links to each server's own",
"administration, found from the server. A value may instead be an object", "administration, found from the server. A value may instead be an object",
"that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.", "that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.",
"STALWART_ADMIN_URL is the same for the default server. A listed domain is", "ADMIN_URL is the same for the default server. A listed domain is",
"never pointed at the default server's administration.", "never pointed at the default server's administration.",
"", "",
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers" "Each listed domain signs in to its own server; everything else goes to MAIL_SERVER_URL."
], ],
"example.com": "https://mail.example.com", "example.com": "https://mail.example.com",
+3 -3
View File
@@ -20,11 +20,11 @@
"test": "npm run test -w web && npm run test -w server", "test": "npm run test -w web && npm run test -w server",
"lint": "npm run typecheck", "lint": "npm run typecheck",
"mock": "npm run mock -w server", "mock": "npm run mock -w server",
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"", "dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"MAIL_SERVER_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"", "dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"MAIL_SERVER_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"i18n:coverage": "node scripts/i18n-coverage.mjs", "i18n:coverage": "node scripts/i18n-coverage.mjs",
"i18n:check": "node scripts/i18n-catalog-check.mjs --check && node scripts/i18n-literals.mjs --check", "i18n:check": "node scripts/i18n-catalog-check.mjs --check && node scripts/i18n-literals.mjs --check",
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"" "dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"MAIL_SERVER_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
+8
View File
@@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export function sourceIdentity(root: string): { ref: string | null; id: string };
export function writeSourceArchive(root: string, outFile: string, name: string, identity: { ref: string | null; id: string }): void;
+105
View File
@@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/*
* The AGPL's offer, for this build: the exact source it was built from,
* written next to the app as `source.tar.gz`, and an identity for it that the
* interface shows beside the download link.
*
* In a git checkout, "exact" includes uncommitted work, new files too: every
* file git doesn't ignore goes into a throwaway index, never the real one, and
* the tree that makes is what gets archived. The identity is HEAD's short id,
* with `+local-<tree>` when the tree differs from HEAD's.
*
* In the Docker build there is no git (.dockerignore keeps .git out on
* purpose), so the build context's files are packed as they are, minus what
* .dockerignore already dropped and what the build made. The identity is then
* a hash of those files' paths and contents, so the same source always gets
* the same name.
*/
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative } from "node:path";
const SKIP = new Set(["node_modules", "dist", ".git", "coverage"]);
/** Local state, never source: the session file's folder. */
const SKIP_PATHS = new Set(["server/data"]);
function git(args, cwd, env) {
return execFileSync("git", args, { cwd, env: env ?? process.env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
}
function hasGit(root) {
try {
return git(["rev-parse", "--is-inside-work-tree"], root) === "true";
} catch {
return false;
}
}
/** Every file that isn't build output, dependencies or local data, sorted. */
function projectFiles(root) {
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (SKIP.has(entry.name) || entry.name === "source.tar.gz" || SKIP_PATHS.has(relative(root, full))) continue;
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) out.push(relative(root, full));
}
};
walk(root);
return out.sort();
}
/** @returns {{ ref: string | null, id: string }} */
export function sourceIdentity(root) {
if (hasGit(root)) {
const dir = mkdtempSync(join(tmpdir(), "inbuxa-source-"));
try {
const env = { ...process.env, GIT_INDEX_FILE: join(dir, "index") };
git(["read-tree", "HEAD"], root, env);
git(["add", "--all", "."], root, env);
const tree = git(["write-tree"], root, env);
const head = git(["rev-parse", "--short=12", "HEAD"], root);
return tree === git(["rev-parse", "HEAD^{tree}"], root)
? { ref: tree, id: head }
: { ref: tree, id: `${head}+local-${tree.slice(0, 12)}` };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const hash = createHash("sha256");
for (const file of projectFiles(root)) {
hash.update(file).update("\0").update(readFileSync(join(root, file))).update("\0");
}
return { ref: null, id: `files-${hash.digest("hex").slice(0, 12)}` };
}
/** Write the archive of `identity`'s tree to `outFile`. */
export function writeSourceArchive(root, outFile, name, identity) {
if (!existsSync(dirname(outFile))) mkdirSync(dirname(outFile), { recursive: true });
const prefix = `${name}-${identity.id}`;
if (identity.ref) {
execFileSync("git", ["archive", "--format=tar.gz", `--prefix=${prefix}/`, "-o", outFile, identity.ref], { cwd: root });
return;
}
// Staged under the prefix and packed from there: BusyBox tar, in the Alpine
// image, can't rewrite paths as it packs.
const stage = mkdtempSync(join(tmpdir(), "inbuxa-source-"));
try {
for (const file of projectFiles(root)) {
const to = join(stage, prefix, file);
mkdirSync(dirname(to), { recursive: true });
copyFileSync(join(root, file), to);
}
execFileSync("tar", ["-czf", outFile, "-C", stage, prefix]);
} finally {
rmSync(stage, { recursive: true, force: true });
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ const PORT = 18797;
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.STALWART_URL = `http://127.0.0.1:${PORT}`; process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-account-flows"; process.env.APP_SECRET = "test-secret-for-account-flows";
const mock = await import("./mock/index.js"); const mock = await import("./mock/index.js");
+1 -1
View File
@@ -72,7 +72,7 @@ async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodRespon
signal: AbortSignal.timeout(config.upstreamTimeout), signal: AbortSignal.timeout(config.upstreamTimeout),
}); });
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401); if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502); if (!res.ok) throw new UpstreamError(`The mail server rejected the request (${res.status})`, 502);
return (await res.json()) as { methodResponses?: [string, unknown, string][] }; return (await res.json()) as { methodResponses?: [string, unknown, string][] };
} }
+6 -6
View File
@@ -14,16 +14,16 @@ writeFileSync(
"Linked.Test.": { url: "https://mail.linked.test", adminUrl: "https://admin.linked.test/" }, "Linked.Test.": { url: "https://mail.linked.test", adminUrl: "https://admin.linked.test/" },
}), }),
); );
process.env.STALWART_URL = "https://default.example"; process.env.MAIL_SERVER_URL = "https://default.example";
process.env.STALWART_ADMIN_URL = "https://admin.default.example/"; process.env.ADMIN_URL = "https://admin.default.example/";
process.env.STALWART_SERVERS_FILE = file; process.env.MAIL_SERVERS_FILE = file;
const { adminPrefixFrom, adminUrlFor, advertisedOrigin, upstreamFor } = await import("./upstream.js"); const { adminPrefixFrom, adminUrlFor, advertisedOrigin, upstreamFor } = await import("./upstream.js");
const { config, parseStalwartServers } = await import("./config.js"); const { config, parseStalwartServers } = await import("./config.js");
/** /**
* Where the dashboard's "Open Stalwart admin" points. STALWART_URL is how this * Where the dashboard's "Open Stalwart admin" points. MAIL_SERVER_URL is how this
* server reaches Stalwart; STALWART_ADMIN_URL is where a browser opens its * server reaches Stalwart; ADMIN_URL is where a browser opens its
* administration, and follows the same domain routing. * administration, and follows the same domain routing.
*/ */
test("a servers file entry may name its administration as well as its server, and a note is not a domain", () => { test("a servers file entry may name its administration as well as its server, and a note is not a domain", () => {
@@ -67,7 +67,7 @@ test("the origin is the one Stalwart advertises, even when it is reached on a pr
}); });
test("the shipped example loads through the parser that reads it", () => { test("the shipped example loads through the parser that reads it", () => {
const example = new URL("../../stalwart-servers.example.json", import.meta.url); const example = new URL("../../mail-servers.example.json", import.meta.url);
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example"); const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
assert.ok(Object.keys(parsed.urls).length > 0); assert.ok(Object.keys(parsed.urls).length > 0);
assert.ok(!("_comment" in parsed.urls)); assert.ok(!("_comment" in parsed.urls));
+2 -2
View File
@@ -1,6 +1,6 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1"; process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js"); const { createApp } = await import("./app.js");
test("CSRF guard rejects API POSTs without the custom header", async () => { test("CSRF guard rejects API POSTs without the custom header", async () => {
@@ -111,7 +111,7 @@ test("only a PDF blob may be framed, and only by us", async () => {
/* /*
* #239: retrying through an outage must not lock somebody out of the recovery. * #239: retrying through an outage must not lock somebody out of the recovery.
* *
* STALWART_URL at the top of this file is 127.0.0.1:1 — nothing listens there, * MAIL_SERVER_URL at the top of this file is 127.0.0.1:1 — nothing listens there,
* so every sign-in here is the outage case. Before the fix, the eleventh of * so every sign-in here is the outage case. Before the fix, the eleventh of
* these came back 429 and stayed 429 for fifteen minutes, outliving whatever * these came back 429 and stayed 429 for fifteen minutes, outliving whatever
* had actually been wrong. * had actually been wrong.
+3 -3
View File
@@ -521,7 +521,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
{ {
error: "unsupported_server", error: "unsupported_server",
message: message:
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.", "Your credentials are fine, but this mail server isn't one this webmail supports.",
}, },
501, 501,
); );
@@ -564,7 +564,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
{ {
error: "totp_unsupported", error: "totp_unsupported",
message: message:
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.", "This mail server does not accept two-factor codes from this form. Sign in with an app password instead. Your password and code are probably fine.",
}, },
401, 401,
); );
@@ -629,7 +629,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
const accountCtx = async (c: Context<Env>) => { const accountCtx = async (c: Context<Env>) => {
const session = c.get("session"); const session = c.get("session");
// The account's own server. Without it, the first fetch after the cached // The account's own server. Without it, the first fetch after the cached
// session expires goes to STALWART_URL -- which, for a domain mapped // session expires goes to MAIL_SERVER_URL -- which, for a domain mapped
// elsewhere, either refuses the password or knows a different account by // elsewhere, either refuses the password or knows a different account by
// the same name (#238). // the same name (#238).
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
+1 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1"; process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js"); const { createApp } = await import("./app.js");
/** /**
+1 -1
View File
@@ -19,7 +19,7 @@ writeFileSync(join(root, "assets", "app.js"), script);
writeFileSync(join(root, "index.html"), `<!doctype html><title>t</title>${"<p>hello</p>".repeat(400)}`); writeFileSync(join(root, "index.html"), `<!doctype html><title>t</title>${"<p>hello</p>".repeat(400)}`);
process.env.STATIC_DIR = root; process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1"; process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js"); const { createApp } = await import("./app.js");
test("an asset is gzipped when the client asks for it", async () => { test("an asset is gzipped when the client asks for it", async () => {
+15 -15
View File
@@ -57,7 +57,7 @@ if (!appSecret || appSecret === "change-me") {
); );
} }
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, ""); const stalwartUrl = env("MAIL_SERVER_URL", "https://mail.example.com").replace(/\/+$/, "");
/** /**
* Declares that this instance is running as an immutable container: read-only * Declares that this instance is running as an immutable container: read-only
@@ -190,7 +190,7 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
/** /**
* Which Stalwart a domain signs in to. * Which Stalwart a domain signs in to.
* *
* `STALWART_URL` stays required and stays the default; this only adds domains * `MAIL_SERVER_URL` stays required and stays the default; this only adds domains
* that go somewhere else (#238). An installation that sets nothing behaves * that go somewhere else (#238). An installation that sets nothing behaves
* exactly as it always has. * exactly as it always has.
* *
@@ -203,15 +203,15 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
* one is unreachable is a sign-in question, answered in #239. * one is unreachable is a sign-in question, answered in #239.
*/ */
function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } { function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } {
const file = process.env.STALWART_SERVERS_FILE; const file = process.env.MAIL_SERVERS_FILE;
if (!file) return { urls: {}, adminUrls: {} }; if (!file) return { urls: {}, adminUrls: {} };
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`); if (!existsSync(file)) throw new Error(`MAIL_SERVERS_FILE does not exist: ${file}`);
let raw: unknown; let raw: unknown;
try { try {
raw = JSON.parse(readFileSync(file, "utf8")); raw = JSON.parse(readFileSync(file, "utf8"));
} catch (err) { } catch (err) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`); throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): ${(err as Error).message}`);
} }
return parseStalwartServers(raw, file); return parseStalwartServers(raw, file);
} }
@@ -219,7 +219,7 @@ function readStalwartServers(): { urls: Record<string, string>; adminUrls: Recor
/** The servers file's contents, checked. Exported so the shipped example is tested by the parser that reads it. */ /** The servers file's contents, checked. Exported so the shipped example is tested by the parser that reads it. */
export function parseStalwartServers(raw: unknown, file: string): { urls: Record<string, string>; adminUrls: Record<string, string> } { export function parseStalwartServers(raw: unknown, file: string): { urls: Record<string, string>; adminUrls: Record<string, string> } {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`); throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): expected an object of domain to URL`);
} }
const out: Record<string, string> = {}; const out: Record<string, string> = {};
@@ -233,16 +233,16 @@ export function parseStalwartServers(raw: unknown, file: string): { urls: Record
taken off a username will arrive and comparing them any other way means taken off a username will arrive and comparing them any other way means
a mapping that silently never matches. */ a mapping that silently never matches. */
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, ""); const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`); if (!domain) throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): a domain key is empty`);
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalized`); if (domain in out) throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): "${domain}" appears twice once normalized`);
/* A domain's value is its server's URL, or an object that also names where /* A domain's value is its server's URL, or an object that also names where
that server's own administration is: `{"url": …, "adminUrl": …}`. */ that server's own administration is: `{"url": …, "adminUrl": …}`. */
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue }; const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
if (typeof value.url !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`); if (typeof value.url !== "string") throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): "${domain}" is not a URL`);
out[domain] = httpUrl(value.url, `STALWART_SERVERS_FILE (${file}): "${domain}"`); out[domain] = httpUrl(value.url, `MAIL_SERVERS_FILE (${file}): "${domain}"`);
if (value.adminUrl !== undefined) { if (value.adminUrl !== undefined) {
if (typeof value.adminUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`); if (typeof value.adminUrl !== "string") throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`);
adminUrls[domain] = httpUrl(value.adminUrl, `STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl`); adminUrls[domain] = httpUrl(value.adminUrl, `MAIL_SERVERS_FILE (${file}): "${domain}" adminUrl`);
} }
} }
return { urls: out, adminUrls }; return { urls: out, adminUrls };
@@ -314,12 +314,12 @@ export const config = {
stalwartServers: stalwartServers.urls, stalwartServers: stalwartServers.urls,
/** /**
* Where an administrator reaches Stalwart's own administration, for the * Where an administrator reaches Stalwart's own administration, for the
* pointer on ihasmail's dashboard. Optional, and separate from STALWART_URL, * pointer on ihasmail's dashboard. Optional, and separate from MAIL_SERVER_URL,
* which is how *this server* reaches Stalwart -- often an address no browser * which is how *this server* reaches Stalwart -- often an address no browser
* can open. Unset, the dashboard names Stalwart's administration without a * can open. Unset, the dashboard names Stalwart's administration without a
* link. A domain routed elsewhere takes its server's `adminUrl` instead. * link. A domain routed elsewhere takes its server's `adminUrl` instead.
*/ */
stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "", stalwartAdminUrl: process.env.ADMIN_URL ? httpUrl(process.env.ADMIN_URL, "ADMIN_URL") : "",
stalwartAdminUrls: stalwartServers.adminUrls, stalwartAdminUrls: stalwartServers.adminUrls,
/** /**
* Say that an Enterprise-only section is Enterprise-only even on an * Say that an Enterprise-only section is Enterprise-only even on an
@@ -385,7 +385,7 @@ export const config = {
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */ /* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0", rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */ /* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */
followAdvertisedUrls: process.env.STALWART_FOLLOW_ADVERTISED_URLS === "1", followAdvertisedUrls: process.env.MAIL_SERVER_FOLLOW_ADVERTISED_URLS === "1",
}; };
export type Config = typeof config; export type Config = typeof config;
+2 -2
View File
@@ -1,7 +1,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
process.env.STALWART_URL = "https://default.example"; process.env.MAIL_SERVER_URL = "https://default.example";
const { upstreamFor } = await import("./upstream.js"); const { upstreamFor } = await import("./upstream.js");
const { config } = await import("./config.js"); const { config } = await import("./config.js");
@@ -9,7 +9,7 @@ const { config } = await import("./config.js");
/** /**
* Which Stalwart a username goes to (#238). * Which Stalwart a username goes to (#238).
* *
* `STALWART_URL` is required and is the default. The mapping only adds domains * `MAIL_SERVER_URL` is required and is the default. The mapping only adds domains
* that go elsewhere, so an installation with no mapping behaves exactly as it * that go elsewhere, so an installation with no mapping behaves exactly as it
* always has -- which is what these first cases pin. * always has -- which is what these first cases pin.
*/ */
+1 -1
View File
@@ -1,7 +1,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1"; process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
process.env.APP_SECRET = "test-secret-for-ics-proxy"; process.env.APP_SECRET = "test-secret-for-ics-proxy";
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js"); const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
+1 -1
View File
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { createServer, request as httpRequest, type IncomingMessage, type Server } from "node:http"; import { createServer, request as httpRequest, type IncomingMessage, type Server } from "node:http";
import { AddressInfo } from "node:net"; import { AddressInfo } from "node:net";
process.env.STALWART_URL = "http://127.0.0.1:1"; process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
process.env.APP_SECRET = "test-secret-for-image-proxy"; process.env.APP_SECRET = "test-secret-for-image-proxy";
const { fetchPinned, isPrivateAddress } = await import("./imageproxy.js"); const { fetchPinned, isPrivateAddress } = await import("./imageproxy.js");
+4 -5
View File
@@ -19,7 +19,7 @@ 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:stalwart:jmap
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`; process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-login-guard"; process.env.APP_SECRET = "test-secret-for-login-guard";
const mock = await import("./mock/index.js"); const mock = await import("./mock/index.js");
@@ -48,13 +48,12 @@ test("a server without the registry is refused, with good credentials", async ()
assert.equal(res.body.error, "unsupported_server"); assert.equal(res.body.error, "unsupported_server");
}); });
test("the message says the credentials were fine, and names the way out", async () => { test("the message says the credentials were fine, and that the server isn't supported", async () => {
const { body } = await login({ username: "[email protected]", password: "demo-password" }); const { body } = await login({ username: "[email protected]", password: "demo-password" });
// Someone hitting this has typed a correct password. Saying so is the // Someone hitting this has typed a correct password. Saying so is the
// difference between "upgrade your server" and "try your password again". // difference between "wrong server" and "try your password again".
assert.match(body.message, /credentials are fine/i); assert.match(body.message, /credentials are fine/i);
assert.match(body.message, /0\.16/); assert.match(body.message, /isn't one this webmail supports/);
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
}); });
test("no session is minted for a server we cannot talk to", async () => { test("no session is minted for a server we cannot talk to", async () => {
+1 -1
View File
@@ -226,7 +226,7 @@ export function createDirectory(opts: Options) {
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0); push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
} }
} }
const applications: Obj[] = [{ id: "app1", description: "Stalwart Web Interface", enabled: true, urlPrefix: { "/admin": true, "/account": true } }]; const applications: Obj[] = [{ id: "app1", description: "Web Interface", enabled: true, urlPrefix: { "/admin": true, "/account": true } }];
/** Tenants: a name, limits, and whatever names them in its memberTenantId. */ /** Tenants: a name, limits, and whatever names them in its memberTenantId. */
const tenants: Obj[] = [ const tenants: Obj[] = [
+2 -2
View File
@@ -1,7 +1,7 @@
/** /**
* A tiny in-memory JMAP server that mimics the subset of Stalwart that ihasmail * A tiny in-memory JMAP server that mimics the subset of Stalwart that ihasmail
* uses. For local development and demos only: `npm run mock` then point the * uses. For local development and demos only: `npm run mock` then point the
* server at it with STALWART_URL=http://127.0.0.1:8788 (user: demo / pass: demo). * server at it with MAIL_SERVER_URL=http://127.0.0.1:8788 (user: demo / pass: demo).
*/ */
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { parseOtpauthUrl, verifyTotp } from "../totp.js"; import { parseOtpauthUrl, verifyTotp } from "../totp.js";
@@ -205,7 +205,7 @@ export const server = createServer(async (req, res) => {
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-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`); console.log(`[mock-stalwart] 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
+1 -1
View File
@@ -11,7 +11,7 @@ const PORT = 18811;
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.STALWART_URL = `http://127.0.0.1:${PORT}`; process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-oauth"; process.env.APP_SECRET = "test-secret-for-oauth";
process.env.OAUTH_CLIENT_SECRET = "mock-oauth-secret"; process.env.OAUTH_CLIENT_SECRET = "mock-oauth-secret";
process.env.PUBLIC_URL = "https://webmail.example.test"; process.env.PUBLIC_URL = "https://webmail.example.test";
+1 -1
View File
@@ -55,7 +55,7 @@ export function oauthEnabled(): boolean {
/** /**
* Whether every account is on the same server. Then sign-in needs no address * Whether every account is on the same server. Then sign-in needs no address
* first: the server's page asks for the username itself. With several servers * first: the server's page asks for the username itself. With several servers
* (STALWART_SERVERS_FILE), the domain picks the server, so the address comes * (MAIL_SERVERS_FILE), the domain picks the server, so the address comes
* first. * first.
*/ */
export function singleServer(): boolean { export function singleServer(): boolean {
+1 -1
View File
@@ -1,7 +1,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
process.env.STALWART_URL = "http://127.0.0.1:1"; process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
process.env.PUSH_URL = "https://ihasmail.example"; process.env.PUSH_URL = "https://ihasmail.example";
const push = await import("./push.js"); const push = await import("./push.js");
+1 -1
View File
@@ -15,7 +15,7 @@ const PORT = 18813;
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.STALWART_URL = `http://127.0.0.1:${PORT}`; process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-request-limits"; process.env.APP_SECRET = "test-secret-for-request-limits";
const mock = await import("./mock/index.js"); const mock = await import("./mock/index.js");
+1 -1
View File
@@ -59,7 +59,7 @@ test("the example's commentary cannot be mistaken for a section", () => {
* reason: an example that no longer loads is worse than no example, because * reason: an example that no longer loads is worse than no example, because
* the first experience of the feature is a server that refuses to start. * the first experience of the feature is a server that refuses to start.
*/ */
const SERVERS = fileURLToPath(new URL("../../stalwart-servers.example.json", import.meta.url)); const SERVERS = fileURLToPath(new URL("../../mail-servers.example.json", import.meta.url));
test("the example server mapping is valid JSON", () => { test("the example server mapping is valid JSON", () => {
assert.doesNotThrow(() => JSON.parse(readFileSync(SERVERS, "utf8"))); assert.doesNotThrow(() => JSON.parse(readFileSync(SERVERS, "utf8")));
+1 -1
View File
@@ -29,7 +29,7 @@ writeFileSync(join(root, "sw.js"), "/* worker */\n");
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>"); writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
process.env.STATIC_DIR = root; process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1"; process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js"); const { createApp } = await import("./app.js");
const app = createApp(); const app = createApp();
+1 -1
View File
@@ -26,7 +26,7 @@ writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
writeFileSync(join(root, "img.png"), "not really a png"); writeFileSync(join(root, "img.png"), "not really a png");
process.env.STATIC_DIR = root; process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1"; process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js"); const { createApp } = await import("./app.js");
const cacheControl = async (path: string) => { const cacheControl = async (path: string) => {
+6 -6
View File
@@ -37,7 +37,7 @@ const SESSION_CACHE_MS = 5 * 60_000;
/** /**
* The Stalwart a username belongs to. * The Stalwart a username belongs to.
* *
* `STALWART_URL` is the default and is always the answer for a domain nobody * `MAIL_SERVER_URL` is the default and is always the answer for a domain nobody
* mapped -- and for a bare username, which Stalwart accepts and which has no * mapped -- and for a bare username, which Stalwart accepts and which has no
* domain to map (#238). * domain to map (#238).
* *
@@ -59,7 +59,7 @@ export function upstreamFor(username: string): string {
* Where the administrator signed in as `username` opens Stalwart's own * Where the administrator signed in as `username` opens Stalwart's own
* administration. * administration.
* *
* What the operator configured wins -- STALWART_ADMIN_URL for the default * What the operator configured wins -- ADMIN_URL for the default
* server, a servers file entry's `adminUrl` for a routed domain -- and what was * server, a servers file entry's `adminUrl` for a routed domain -- and what was
* found on the account's own server (`detected`) is used otherwise. Routing is * found on the account's own server (`detected`) is used otherwise. Routing is
* the same as `upstreamFor`: a routed domain is never pointed at the default * the same as `upstreamFor`: a routed domain is never pointed at the default
@@ -93,7 +93,7 @@ export function adminPrefixFrom(responses: [string, Record<string, unknown>, str
/** /**
* The public origin a Stalwart session belongs to: the host it advertises in * The public origin a Stalwart session belongs to: the host it advertises in
* its own URLs, which is the address people reach it at even when this server * its own URLs, which is the address people reach it at even when this server
* talks to it on a private one (STALWART_URL=http://127.0.0.1:…). A relative * talks to it on a private one (MAIL_SERVER_URL=http://127.0.0.1:…). A relative
* URL falls back to the configured base. * URL falls back to the configured base.
*/ */
export function advertisedOrigin(session: Pick<UpstreamSession, "apiUrl" | "baseUrl">): string | null { export function advertisedOrigin(session: Pick<UpstreamSession, "apiUrl" | "baseUrl">): string | null {
@@ -427,7 +427,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
}; };
} }
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */ /** Resolve a possibly-relative upstream URL template against MAIL_SERVER_URL. */
/** /**
* Resolve a URL Stalwart handed us against the server we were configured to * Resolve a URL Stalwart handed us against the server we were configured to
* talk to. * talk to.
@@ -435,7 +435,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
* Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl * Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl
* and the rest -- built from its public hostname, which is always https. A * and the rest -- built from its public hostname, which is always https. A
* proxy that follows them takes every upstream call, and every held push * proxy that follows them takes every upstream call, and every held push
* stream, out through the public route even when STALWART_URL names a private * stream, out through the public route even when MAIL_SERVER_URL names a private
* plain-HTTP hop on the same network. Measured, that TLS leg is ~80 KiB of * plain-HTTP hop on the same network. Measured, that TLS leg is ~80 KiB of
* native OpenSSL state per signed-in tab: 60% of what a tab costs, and the * native OpenSSL state per signed-in tab: 60% of what a tab costs, and the
* whole difference between 1,665 and 3,680 tabs in 256 MiB. * whole difference between 1,665 and 3,680 tabs in 256 MiB.
@@ -443,7 +443,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
* So by default only the path and query are taken from the advertised URL; * So by default only the path and query are taken from the advertised URL;
* scheme, host and port come from the configured base. That is what a proxy * scheme, host and port come from the configured base. That is what a proxy
* should have done all along -- the operator named the route on purpose. * should have done all along -- the operator named the route on purpose.
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behavior for a setup * MAIL_SERVER_FOLLOW_ADVERTISED_URLS=1 restores the old behavior for a setup
* that genuinely needs to reach Stalwart at a different origin than the one * that genuinely needs to reach Stalwart at a different origin than the one
* it was given. * it was given.
*/ */
+2
View File
@@ -6,3 +6,5 @@
* `scripts/version.mjs`. * `scripts/version.mjs`.
*/ */
declare const __IHASMAIL_VERSION__: string; declare const __IHASMAIL_VERSION__: string;
/** ihasmail-inbuxa: the identity of the source this build was made from (scripts/source-archive.mjs). */
declare const __SOURCE_ID__: string;
+1 -1
View File
@@ -40,7 +40,7 @@ export interface JmapSession {
server?: { server?: {
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */ /** "oss" | "community" | "enterprise". Stalwart publishes no version. */
edition?: string | null; edition?: string | null;
/** Where Stalwart's own administration is (STALWART_ADMIN_URL), for a session that may administer. */ /** Where Stalwart's own administration is (ADMIN_URL), for a session that may administer. */
adminUrl?: string | null; adminUrl?: string | null;
/** SHOW_ENTERPRISE_NOTICES: an Enterprise-only section says so even on Enterprise. */ /** SHOW_ENTERPRISE_NOTICES: an Enterprise-only section says so even on Enterprise. */
enterpriseNotices?: boolean; enterpriseNotices?: boolean;
+7
View File
@@ -6,3 +6,10 @@
* parts are what they are. * parts are what they are.
*/ */
export const APP_VERSION = __IHASMAIL_VERSION__; export const APP_VERSION = __IHASMAIL_VERSION__;
/**
* ihasmail-inbuxa: the source this build was made from, which the build writes
* next to the app as `source.tar.gz`. The AGPL's offer links there.
*/
export const SOURCE_ID = __SOURCE_ID__;
export const SOURCE_ARCHIVE = "/source.tar.gz";
+11 -12
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 Stalwart's own administration.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in der Verwaltung von Stalwart selbst.", "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 Stalwart admin": "Stalwart-Verwaltung ö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",
@@ -217,10 +217,10 @@ export const catalog: Catalog = {
"New role": "Neue Rolle", "New role": "Neue Rolle",
"This role carries permissions yours doesn't, so you can view it but not change it.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat. Sie können sie ansehen, aber nicht ändern.", "This role carries permissions yours doesn't, so you can view it but not change it.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat. Sie können sie ansehen, aber nicht ändern.",
"Your role lets you view roles but not change them.": "Ihre Rolle erlaubt es, Rollen anzusehen, aber nicht zu ändern.", "Your role lets you view roles but not change them.": "Ihre Rolle erlaubt es, Rollen anzusehen, aber nicht zu ändern.",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart 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",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart vergibt diese Rolle standardmäßig, daher kann sie nicht gelöscht werden. Ändern Sie zuerst die Standardwerte in der Verwaltung von Stalwart selbst.", "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",
@@ -247,7 +247,7 @@ export const catalog: Catalog = {
"Delete role": "Rolle löschen", "Delete role": "Rolle löschen",
"It can't be undone.": "Das lässt sich nicht rückgängig machen.", "It can't be undone.": "Das lässt sich nicht rückgängig machen.",
"Type {name} to confirm": "Geben Sie {name} zur Bestätigung ein", "Type {name} to confirm": "Geben Sie {name} zur Bestätigung ein",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Die Berechtigungsliste von Stalwart konnte nicht geladen werden, daher lassen sich Berechtigungen hier nicht ändern. ({reason})", "The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Die Berechtigungsliste des Mailservers konnte nicht geladen werden, daher lassen sich Berechtigungen hier nicht ändern. ({reason})",
"Named sets of permissions, given to accounts, groups and tenants.": "Benannte Sätze von Berechtigungen, vergeben an Konten, Gruppen und Mandanten.", "Named sets of permissions, given to accounts, groups and tenants.": "Benannte Sätze von Berechtigungen, vergeben an Konten, Gruppen und Mandanten.",
"Search roles": "Rollen durchsuchen", "Search roles": "Rollen durchsuchen",
"No roles match": "Keine passenden Rollen", "No roles match": "Keine passenden Rollen",
@@ -270,11 +270,11 @@ export const catalog: Catalog = {
"{used} used": "{used} belegt", "{used} used": "{used} belegt",
"Your role lets you view tenants but not change them.": "Ihre Rolle erlaubt es, Mandanten anzusehen, aber nicht zu ändern.", "Your role lets you view tenants but not change them.": "Ihre Rolle erlaubt es, Mandanten anzusehen, aber nicht zu ändern.",
"Logo": "Logo", "Logo": "Logo",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Eine https-Adresse oder eine Data-URL eines Bildes. Stalwart zeigt es den Personen des Mandanten dort, wo es ein Logo zeigt.", "An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Eine https-Adresse oder eine Data-URL eines Bildes. Der Mailserver zeigt es den Personen des Mandanten dort, wo er ein Logo zeigt.",
"What it holds": "Enthält", "What it holds": "Enthält",
"{n} of {limit}": "{n} von {limit}", "{n} of {limit}": "{n} von {limit}",
"Limits": "Limits", "Limits": "Limits",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart verweigert es, mehr anzulegen, als ein Limit erlaubt. Ein leeres Feld bedeutet kein Limit.", "The mail server refuses to create more than a limit allows. An empty field is no limit.": "Der Mailserver verweigert es, mehr anzulegen, als ein Limit erlaubt. Ein leeres Feld bedeutet kein Limit.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Das Höchste, was jemandem in diesem Mandanten erlaubt sein kann: Die eigenen Rollen werden auf das beschränkt, was diese gewähren. Angeboten werden nur Rollen, deren Berechtigungen Sie selbst haben.", "The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Das Höchste, was jemandem in diesem Mandanten erlaubt sein kann: Die eigenen Rollen werden auf das beschränkt, was diese gewähren. Angeboten werden nur Rollen, deren Berechtigungen Sie selbst haben.",
"Checking what is still in this tenant…": "Es wird geprüft, was noch in diesem Mandanten ist…", "Checking what is still in this tenant…": "Es wird geprüft, was noch in diesem Mandanten ist…",
"It still holds accounts, domains or other things. Move them out first.": "Er enthält noch Konten, Domains oder anderes. Verschieben Sie diese zuerst.", "It still holds accounts, domains or other things. Move them out first.": "Er enthält noch Konten, Domains oder anderes. Verschieben Sie diese zuerst.",
@@ -290,7 +290,6 @@ export const catalog: Catalog = {
"Still holds {things}. Move them out first.": "Enthält noch {things}. Verschieben Sie diese zuerst.", "Still holds {things}. Move them out first.": "Enthält noch {things}. Verschieben Sie diese zuerst.",
"Delete tenant": "Mandant löschen", "Delete tenant": "Mandant löschen",
"Separate organizations on one server, each with its own people, domains and limits.": "Getrennte Organisationen auf einem Server, jede mit eigenen Personen, Domains und Limits.", "Separate organizations on one server, each with its own people, domains and limits.": "Getrennte Organisationen auf einem Server, jede mit eigenen Personen, Domains und Limits.",
"Tenants are a Stalwart Enterprise feature.": "Mandanten sind eine Funktion von Stalwart Enterprise.",
"Search tenants": "Mandanten durchsuchen", "Search tenants": "Mandanten durchsuchen",
"No tenants match": "Keine passenden Mandanten", "No tenants match": "Keine passenden Mandanten",
"No tenants yet": "Noch keine Mandanten", "No tenants yet": "Noch keine Mandanten",
@@ -1081,14 +1080,14 @@ export const catalog: Catalog = {
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Ihr Mailserver stellt diese direkt an Ihren Browser zu, sodass sie auch ohne geöffneten ihasmail-Tab ankommen — mit Absender und Betreff. Ihr Browser muss dennoch laufen: Beenden Sie ihn vollständig, warten die Benachrichtigungen und kommen an, sobald Sie ihn wieder öffnen.", "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Ihr Mailserver stellt diese direkt an Ihren Browser zu, sodass sie auch ohne geöffneten ihasmail-Tab ankommen — mit Absender und Betreff. Ihr Browser muss dennoch laufen: Beenden Sie ihn vollständig, warten die Benachrichtigungen und kommen an, sobald Sie ihn wieder öffnen.",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Ihr Mailserver kann diesen Browser wecken, übermittelt aber weder Absender noch Betreff. Ihr Browser muss dennoch laufen.", "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Ihr Mailserver kann diesen Browser wecken, übermittelt aber weder Absender noch Betreff. Ihr Browser muss dennoch laufen.",
"This is what a new-mail notification looks like.": "So sieht eine Benachrichtigung über neue Post aus.", "This is what a new-mail notification looks like.": "So sieht eine Benachrichtigung über neue Post aus.",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Sie sind als {user} angemeldet. Ihr Passwort wird nie im Browser gespeichert; der Server hält es pro Sitzung verschlüsselt vor, um mit Stalwart zu kommunizieren.", "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.": "Sie sind als {user} angemeldet. Ihr Passwort wird nie im Browser gespeichert; der Server hält es pro Sitzung verschlüsselt vor, um mit dem Mailserver zu kommunizieren.",
"App passwords are managed by your mail administrator.": "App-Passwörter werden von Ihrer Mail-Administration verwaltet.", "App passwords are managed by your mail administrator.": "App-Passwörter werden von Ihrer Mail-Administration verwaltet.",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Wenn Sie Ihr Passwort ändern, werden Ihre anderen Webmail-Sitzungen abgemeldet. App-Passwörter funktionieren weiterhin.", "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Wenn Sie Ihr Passwort ändern, werden Ihre anderen Webmail-Sitzungen abgemeldet. App-Passwörter funktionieren weiterhin.",
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Für dieses Konto ist die Zwei-Faktor-Authentifizierung aktiviert. ihasmail kann Sie noch nicht per Code anmelden; die Anmeldung auf einem anderen Gerät benötigt daher ein App-Passwort — oder Sie deaktivieren die Zwei-Faktor-Authentifizierung hier.", "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Für dieses Konto ist die Zwei-Faktor-Authentifizierung aktiviert. ihasmail kann Sie noch nicht per Code anmelden; die Anmeldung auf einem anderen Gerät benötigt daher ein App-Passwort — oder Sie deaktivieren die Zwei-Faktor-Authentifizierung hier.",
"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.": "Ein eigenes Passwort für ein E-Mail-Programm oder Gerät, das Sie einzeln widerrufen können. App-Passwörter umgehen Zwei-Faktor-Codes und funktionieren daher auch in Programmen, die keinen abfragen können.", "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.": "Ein eigenes Passwort für ein E-Mail-Programm oder Gerät, das Sie einzeln widerrufen können. App-Passwörter umgehen Zwei-Faktor-Codes und funktionieren daher auch in Programmen, die keinen abfragen können.",
"Copy it into {name} now — it isn't shown again.": "Übertragen Sie es jetzt nach {name} — es wird nicht erneut angezeigt.", "Copy it into {name} now — it isn't shown again.": "Übertragen Sie es jetzt nach {name} — es wird nicht erneut angezeigt.",
"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.": "Im Verzeichnis wurden keine weiteren Benutzer gefunden, es kann also niemand Neues hinzugefügt werden. Bestehende Freigaben sind unten aufgeführt und können weiterhin entfernt werden.", "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.": "Im Verzeichnis wurden keine weiteren Benutzer gefunden, es kann also niemand Neues hinzugefügt werden. Bestehende Freigaben sind unten aufgeführt und können weiterhin entfernt werden.",
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart gibt seine Versionsnummer nicht an E-Mail-Programme weiter, daher nennt ihasmail die Edition, sofern der Server eine angibt. ihasmail benötigt 0.16 oder neuer; die Anmeldung verweigert ältere Versionen.", "This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.": "Diese Webmail arbeitet mit dem INBUXA-Mailserver, und die Anmeldung verweigert einen Server, der nicht bietet, was sie braucht.",
"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.": "Es {damage}, daher können die enthaltenen Regeln weder angezeigt noch bearbeitet werden — das Speichern des angekommenen Teils würde den Rest überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut. Ihre Regeln liegen weiterhin auf dem Server; hier wurde nichts daran geändert.", "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.": "Es {damage}, daher können die enthaltenen Regeln weder angezeigt noch bearbeitet werden — das Speichern des angekommenen Teils würde den Rest überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut. Ihre Regeln liegen weiterhin auf dem Server; hier wurde nichts daran geändert.",
"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).": "Der visuelle Regeleditor verwaltet nur Skripte, die er selbst erstellt hat. Sie können das Skript im Reiter {tab} bearbeiten oder neu mit Regeln beginnen (das vorhandene Skript bleibt erhalten, wird aber deaktiviert).", "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).": "Der visuelle Regeleditor verwaltet nur Skripte, die er selbst erstellt hat. Sie können das Skript im Reiter {tab} bearbeiten oder neu mit Regeln beginnen (das vorhandene Skript bleibt erhalten, wird aber deaktiviert).",
"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.": "Ihr Filterskript {damage}, daher ist nur ein Teil angekommen. Eine Regel hinzuzufügen würde diesen Teil über das Ganze schreiben. Laden Sie die Seite neu und versuchen Sie es erneut.", "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.": "Ihr Filterskript {damage}, daher ist nur ein Teil angekommen. Eine Regel hinzuzufügen würde diesen Teil über das Ganze schreiben. Laden Sie die Seite neu und versuchen Sie es erneut.",
@@ -1138,8 +1137,7 @@ export const catalog: Catalog = {
"Drop here for the top level": "Hierher ziehen für die oberste Ebene", "Drop here for the top level": "Hierher ziehen für die oberste Ebene",
// ── Remaining prose ──────────────────────────────────────────────── // ── Remaining prose ────────────────────────────────────────────────
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "Die Version von ihasmail ist das Datum des Commits, aus dem es gebaut wurde, gefolgt davon, woher dieser Commit stammt: {example} wurde aus einem Commit vom 30. August 2026 gebaut, der über Pull Request 129 kam. Ein Commit, der nicht über einen solchen kam, trägt stattdessen seinen kurzen SHA — {sha}. Die Version sagt bewusst nichts über Stalwart aus; was dieser Build vom Server benötigt, steht in der Zeile darüber.", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "Die Version von ihasmail ist das Datum des Commits, aus dem es gebaut wurde, gefolgt davon, woher dieser Commit stammt: {example} wurde aus einem Commit vom 30. August 2026 gebaut, der über Pull Request 129 kam. Ein Commit, der nicht über einen solchen kam, trägt stattdessen seinen kurzen SHA — {sha}. Die Version sagt bewusst nichts über den Mailserver aus; was dieser Build vom Server benötigt, steht in der Zeile darüber.",
// ── Weekdays, schedule presets, rule operators ───────────────────── // ── Weekdays, schedule presets, rule operators ─────────────────────
// Header names (List-Id, X-Spam-Status) stay English: they are the actual // Header names (List-Id, X-Spam-Status) stay English: they are the actual
// field names in the message, not words. // field names in the message, not words.
@@ -1242,6 +1240,7 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Filter konnten nicht gespeichert werden: {error}", "Could not save filters: {error}": "Filter konnten nicht gespeichert werden: {error}",
"Could not send the receipt: {error}": "Die Lesebestätigung konnte nicht gesendet werden: {error}", "Could not send the receipt: {error}": "Die Lesebestätigung konnte nicht gesendet werden: {error}",
"Could not sign in.": "Anmeldung fehlgeschlagen.", "Could not sign in.": "Anmeldung fehlgeschlagen.",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Sie sind als {user} angemeldet. Diese Webmail sieht Ihr Passwort nie: Sie hält ein Anmelde-Token Ihres Mailservers, pro Sitzung verschlüsselt.",
"About INBUXA webmail": "Über INBUXA Webmail", "About INBUXA webmail": "Über INBUXA Webmail",
"Mail server": "Mailserver", "Mail server": "Mailserver",
"You'll enter your password on your mail server's sign-in page.": "Ihr Passwort geben Sie auf der Anmeldeseite Ihres Mailservers ein.", "You'll enter your password on your mail server's sign-in page.": "Ihr Passwort geben Sie auf der Anmeldeseite Ihres Mailservers ein.",
+11 -12
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 Stalwart's own administration.": "Las métricas detalladas, la cola de entrega, los registros y la configuración del servidor están en la administración de Stalwart.", "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 Stalwart admin": "Abrir la administración de Stalwart", "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",
@@ -209,10 +209,10 @@ export const catalog: Catalog = {
"New role": "Nuevo rol", "New role": "Nuevo rol",
"This role carries permissions yours doesn't, so you can view it but not change it.": "Este rol tiene permisos que el suyo no tiene, así que puede verlo pero no modificarlo.", "This role carries permissions yours doesn't, so you can view it but not change it.": "Este rol tiene permisos que el suyo no tiene, así que puede verlo pero no modificarlo.",
"Your role lets you view roles but not change them.": "Su rol le permite ver los roles, pero no modificarlos.", "Your role lets you view roles but not change them.": "Su rol le permite ver los roles, pero no modificarlos.",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart asigna este rol de forma predeterminada a {kinds}. Un cambio aquí afecta a todos los que lo tienen de esa forma.", "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",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart asigna este rol de forma predeterminada, así que no se puede eliminar. Cambie primero los valores predeterminados en la administración de Stalwart.", "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",
@@ -239,7 +239,7 @@ export const catalog: Catalog = {
"Delete role": "Eliminar rol", "Delete role": "Eliminar rol",
"It can't be undone.": "No se puede deshacer.", "It can't be undone.": "No se puede deshacer.",
"Type {name} to confirm": "Escriba {name} para confirmar", "Type {name} to confirm": "Escriba {name} para confirmar",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "No se pudo cargar la lista de permisos de Stalwart, así que aquí no se pueden cambiar los permisos. ({reason})", "The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "No se pudo cargar la lista de permisos del servidor de correo, así que aquí no se pueden cambiar los permisos. ({reason})",
"Named sets of permissions, given to accounts, groups and tenants.": "Conjuntos de permisos con nombre, asignados a cuentas, grupos e inquilinos.", "Named sets of permissions, given to accounts, groups and tenants.": "Conjuntos de permisos con nombre, asignados a cuentas, grupos e inquilinos.",
"Search roles": "Buscar roles", "Search roles": "Buscar roles",
"No roles match": "Ningún rol coincide", "No roles match": "Ningún rol coincide",
@@ -262,11 +262,11 @@ export const catalog: Catalog = {
"{used} used": "{used} usados", "{used} used": "{used} usados",
"Your role lets you view tenants but not change them.": "Su rol le permite ver los inquilinos, pero no modificarlos.", "Your role lets you view tenants but not change them.": "Su rol le permite ver los inquilinos, pero no modificarlos.",
"Logo": "Logotipo", "Logo": "Logotipo",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Una dirección https o una URL data de una imagen. Stalwart la muestra a las personas del inquilino donde muestra un logotipo.", "An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Una dirección https o una URL de datos de una imagen. El servidor de correo la muestra a las personas del inquilino donde muestra un logotipo.",
"What it holds": "Contenido", "What it holds": "Contenido",
"{n} of {limit}": "{n} de {limit}", "{n} of {limit}": "{n} de {limit}",
"Limits": "Límites", "Limits": "Límites",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart se niega a crear más de lo que permite un límite. Un campo vacío significa sin límite.", "The mail server refuses to create more than a limit allows. An empty field is no limit.": "El servidor de correo se niega a crear más de lo que permite un límite. Un campo vacío significa sin límite.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Lo máximo que se puede permitir a cualquiera en este inquilino: sus propios roles se reducen a lo que estos conceden. Solo se ofrecen los roles cuyos permisos usted mismo tiene.", "The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Lo máximo que se puede permitir a cualquiera en este inquilino: sus propios roles se reducen a lo que estos conceden. Solo se ofrecen los roles cuyos permisos usted mismo tiene.",
"Checking what is still in this tenant…": "Comprobando lo que aún hay en este inquilino…", "Checking what is still in this tenant…": "Comprobando lo que aún hay en este inquilino…",
"It still holds accounts, domains or other things. Move them out first.": "Aún tiene cuentas, dominios u otros elementos. Muévalos primero.", "It still holds accounts, domains or other things. Move them out first.": "Aún tiene cuentas, dominios u otros elementos. Muévalos primero.",
@@ -282,7 +282,6 @@ export const catalog: Catalog = {
"Still holds {things}. Move them out first.": "Aún tiene {things}. Muévalos primero.", "Still holds {things}. Move them out first.": "Aún tiene {things}. Muévalos primero.",
"Delete tenant": "Eliminar inquilino", "Delete tenant": "Eliminar inquilino",
"Separate organizations on one server, each with its own people, domains and limits.": "Organizaciones separadas en un mismo servidor, cada una con sus propias personas, dominios y límites.", "Separate organizations on one server, each with its own people, domains and limits.": "Organizaciones separadas en un mismo servidor, cada una con sus propias personas, dominios y límites.",
"Tenants are a Stalwart Enterprise feature.": "Los inquilinos son una función de Stalwart Enterprise.",
"Search tenants": "Buscar inquilinos", "Search tenants": "Buscar inquilinos",
"No tenants match": "Ningún inquilino coincide", "No tenants match": "Ningún inquilino coincide",
"No tenants yet": "Aún no hay inquilinos", "No tenants yet": "Aún no hay inquilinos",
@@ -1143,14 +1142,14 @@ export const catalog: Catalog = {
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Su servidor de correo las entrega directamente a su navegador, así que llegan sin ninguna pestaña de ihasmail abierta, con el remitente y el asunto. Aun así, su navegador debe estar en marcha: si lo cierra por completo, las notificaciones esperan y llegan cuando vuelva a abrirlo.", "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Su servidor de correo las entrega directamente a su navegador, así que llegan sin ninguna pestaña de ihasmail abierta, con el remitente y el asunto. Aun así, su navegador debe estar en marcha: si lo cierra por completo, las notificaciones esperan y llegan cuando vuelva a abrirlo.",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Su servidor de correo puede despertar a este navegador, pero no incluirá el remitente ni el asunto. Aun así, su navegador debe estar en marcha.", "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Su servidor de correo puede despertar a este navegador, pero no incluirá el remitente ni el asunto. Aun así, su navegador debe estar en marcha.",
"This is what a new-mail notification looks like.": "Así es una notificación de correo nuevo.", "This is what a new-mail notification looks like.": "Así es una notificación de correo nuevo.",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Ha iniciado sesión como {user}. Su contraseña nunca se guarda en el navegador; el servidor la conserva cifrada por sesión para comunicarse con Stalwart.", "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.": "Ha iniciado sesión como {user}. Su contraseña nunca se guarda en el navegador; el servidor la conserva cifrada por sesión para comunicarse con el servidor de correo.",
"App passwords are managed by your mail administrator.": "Las contraseñas de aplicación las gestiona su administrador de correo.", "App passwords are managed by your mail administrator.": "Las contraseñas de aplicación las gestiona su administrador de correo.",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Cambiar la contraseña cierra sus demás sesiones de webmail. Las contraseñas de aplicación siguen funcionando.", "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Cambiar la contraseña cierra sus demás sesiones de webmail. Las contraseñas de aplicación siguen funcionando.",
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Esta cuenta tiene activada la autenticación en dos pasos. ihasmail todavía no puede iniciar su sesión con un código, así que iniciar sesión en otro dispositivo requiere una contraseña de aplicación; o puede desactivar aquí la autenticación en dos pasos.", "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Esta cuenta tiene activada la autenticación en dos pasos. ihasmail todavía no puede iniciar su sesión con un código, así que iniciar sesión en otro dispositivo requiere una contraseña de aplicación; o puede desactivar aquí la autenticación en dos pasos.",
"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.": "Una contraseña aparte para una aplicación de correo o un dispositivo, que puede revocar por separado. Las contraseñas de aplicación se saltan los códigos de dos pasos, así que siguen funcionando en aplicaciones que no pueden pedir uno.", "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.": "Una contraseña aparte para una aplicación de correo o un dispositivo, que puede revocar por separado. Las contraseñas de aplicación se saltan los códigos de dos pasos, así que siguen funcionando en aplicaciones que no pueden pedir uno.",
"Copy it into {name} now — it isn't shown again.": "Cópiela ahora en {name}: no se volverá a mostrar.", "Copy it into {name} now — it isn't shown again.": "Cópiela ahora en {name}: no se volverá a mostrar.",
"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.": "No se han encontrado más usuarios en el directorio, así que no se puede añadir a nadie nuevo. Lo que ya está compartido aparece abajo y todavía se puede quitar.", "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.": "No se han encontrado más usuarios en el directorio, así que no se puede añadir a nadie nuevo. Lo que ya está compartido aparece abajo y todavía se puede quitar.",
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart no comunica su número de versión a los clientes de correo, así que ihasmail indica la edición cuando el servidor la proporciona. ihasmail requiere la versión 0.16 o posterior, y el inicio de sesión rechaza cualquier versión anterior.", "This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.": "Este webmail funciona con el servidor de correo INBUXA, y el inicio de sesión rechaza un servidor que no ofrezca lo que necesita.",
"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.": "{damage}, así que las reglas que contiene no se pueden mostrar ni editar: guardar lo que sí llegó sobrescribiría el resto. Recargue la página para intentarlo de nuevo. Sus reglas siguen en el servidor; aquí no se ha cambiado nada.", "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.": "{damage}, así que las reglas que contiene no se pueden mostrar ni editar: guardar lo que sí llegó sobrescribiría el resto. Recargue la página para intentarlo de nuevo. Sus reglas siguen en el servidor; aquí no se ha cambiado nada.",
"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).": "El editor visual de reglas solo gestiona los scripts que él mismo ha creado. Puede editar el script en la pestaña {tab}, o empezar de nuevo con reglas (el script existente se conservará pero quedará desactivado).", "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).": "El editor visual de reglas solo gestiona los scripts que él mismo ha creado. Puede editar el script en la pestaña {tab}, o empezar de nuevo con reglas (el script existente se conservará pero quedará desactivado).",
"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.": "Su script de filtrado {damage}, así que solo ha llegado en parte. Añadir una regla escribiría esa parte sobre el conjunto. Recargue la página e inténtelo de nuevo.", "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.": "Su script de filtrado {damage}, así que solo ha llegado en parte. Añadir una regla escribiría esa parte sobre el conjunto. Recargue la página e inténtelo de nuevo.",
@@ -1159,8 +1158,7 @@ export const catalog: Catalog = {
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Aquí solo aparecen los idiomas a los que se ha traducido ihasmail, así que la lista crece a medida que llegan las traducciones y no antes: un idioma ofrecido sin textos detrás haría que la página afirmara estar en un idioma que no es el suyo.", "Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Aquí solo aparecen los idiomas a los que se ha traducido ihasmail, así que la lista crece a medida que llegan las traducciones y no antes: un idioma ofrecido sin textos detrás haría que la página afirmara estar en un idioma que no es el suyo.",
"tell us about it": "cuéntenoslo", "tell us about it": "cuéntenoslo",
"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}.": "Esta traducción la ha generado una IA y no la ha revisado ninguna persona de habla nativa, así que está marcada como Beta hasta que alguien la dé por buena. Todo lo que suene mal merece un aviso: {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}.": "Esta traducción la ha generado una IA y no la ha revisado ninguna persona de habla nativa, así que está marcada como Beta hasta que alguien la dé por buena. Todo lo que suene mal merece un aviso: {report}.",
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "La versión de ihasmail es la fecha del commit a partir del cual se compiló, seguida de su procedencia: {example} se compiló a partir de un commit del 30 de agosto de 2026 que llegó mediante la pull request 129. Un commit que no llegó por esa vía lleva en su lugar su SHA corto: {sha}. La versión no dice nada sobre Stalwart a propósito; lo que esta compilación necesita del servidor está en la línea de arriba.", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "La versión de ihasmail es la fecha del commit a partir del cual se compiló, seguida de su procedencia: {example} se compiló a partir de un commit del 30 de agosto de 2026 que llegó mediante la pull request 129. Un commit que no llegó por esa vía lleva en su lugar su SHA corto: {sha}. La versión no dice nada sobre el servidor de correo a propósito; lo que esta compilación necesita del servidor está en la línea de arriba.",
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Mensaje nuevo", "New message": "Mensaje nuevo",
"Start a new message with what was shared?": "¿Empezar un mensaje nuevo con lo que se ha compartido?", "Start a new message with what was shared?": "¿Empezar un mensaje nuevo con lo que se ha compartido?",
@@ -1215,6 +1213,7 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "No se pudieron guardar los filtros: {error}", "Could not save filters: {error}": "No se pudieron guardar los filtros: {error}",
"Could not send the receipt: {error}": "No se pudo enviar la confirmación de lectura: {error}", "Could not send the receipt: {error}": "No se pudo enviar la confirmación de lectura: {error}",
"Could not sign in.": "No se pudo iniciar sesión.", "Could not sign in.": "No se pudo iniciar sesión.",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Ha iniciado sesión como {user}. Este webmail nunca ve su contraseña: guarda un token de inicio de sesión de su servidor de correo, cifrado por sesión.",
"About INBUXA webmail": "Acerca de INBUXA webmail", "About INBUXA webmail": "Acerca de INBUXA webmail",
"Mail server": "Servidor de correo", "Mail server": "Servidor de correo",
"You'll enter your password on your mail server's sign-in page.": "Introducirá su contraseña en la página de inicio de sesión de su servidor de correo.", "You'll enter your password on your mail server's sign-in page.": "Introducirá su contraseña en la página de inicio de sesión de su servidor de correo.",
+11 -12
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 Stalwart's own administration.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans ladministration de Stalwart.", "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 Stalwart admin": "Ouvrir ladministration de Stalwart", "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 dune adresse.", "A group needs an address.": "Un groupe a besoin dune adresse.",
"New group": "Nouveau groupe", "New group": "Nouveau groupe",
@@ -214,10 +214,10 @@ export const catalog: Catalog = {
"New role": "Nouveau rôle", "New role": "Nouveau rôle",
"This role carries permissions yours doesn't, so you can view it but not change it.": "Ce rôle comporte des autorisations que le vôtre na pas : vous pouvez le consulter, mais pas le modifier.", "This role carries permissions yours doesn't, so you can view it but not change it.": "Ce rôle comporte des autorisations que le vôtre na pas : vous pouvez le consulter, mais pas le modifier.",
"Your role lets you view roles but not change them.": "Votre rôle vous permet de consulter les rôles, mais pas de les modifier.", "Your role lets you view roles but not change them.": "Votre rôle vous permet de consulter les rôles, mais pas de les modifier.",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart attribue ce rôle par défaut aux {kinds}. Une modification ici touche tous ceux qui lont de cette façon.", "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 sapplique à tous ceux qui lont ainsi.",
"Builds on": "Sappuie sur", "Builds on": "Sappuie sur",
"Permissions": "Autorisations", "Permissions": "Autorisations",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart attribue ce rôle par défaut, il ne peut donc pas être supprimé. Modifiez dabord les valeurs par défaut dans ladministration de Stalwart.", "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 dabord 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 na pas.", "This role carries permissions yours doesn't.": "Ce rôle comporte des autorisations que le vôtre na pas.",
"Create role": "Créer le rôle", "Create role": "Créer le rôle",
"builds on this one": "sappuie sur celui-ci", "builds on this one": "sappuie sur celui-ci",
@@ -244,7 +244,7 @@ export const catalog: Catalog = {
"Delete role": "Supprimer le rôle", "Delete role": "Supprimer le rôle",
"It can't be undone.": "Cest irréversible.", "It can't be undone.": "Cest irréversible.",
"Type {name} to confirm": "Saisissez {name} pour confirmer", "Type {name} to confirm": "Saisissez {name} pour confirmer",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "La liste des autorisations de Stalwart na pas pu être chargée : les autorisations ne peuvent donc pas être modifiées ici. ({reason})", "The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "La liste des autorisations du serveur de messagerie na pas pu être chargée : les autorisations ne peuvent donc pas être modifiées ici. ({reason})",
"Named sets of permissions, given to accounts, groups and tenants.": "Des ensembles nommés dautorisations, attribués aux comptes, groupes et locataires.", "Named sets of permissions, given to accounts, groups and tenants.": "Des ensembles nommés dautorisations, attribués aux comptes, groupes et locataires.",
"Search roles": "Rechercher des rôles", "Search roles": "Rechercher des rôles",
"No roles match": "Aucun rôle ne correspond", "No roles match": "Aucun rôle ne correspond",
@@ -267,11 +267,11 @@ export const catalog: Catalog = {
"{used} used": "{used} utilisés", "{used} used": "{used} utilisés",
"Your role lets you view tenants but not change them.": "Votre rôle vous permet de consulter les locataires, mais pas de les modifier.", "Your role lets you view tenants but not change them.": "Votre rôle vous permet de consulter les locataires, mais pas de les modifier.",
"Logo": "Logo", "Logo": "Logo",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Une adresse https ou une URL data dimage. Stalwart laffiche aux personnes du locataire là où il affiche un logo.", "An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Une adresse https ou une URL de données dune image. Le serveur de messagerie laffiche aux personnes du locataire là où il affiche un logo.",
"What it holds": "Contenu", "What it holds": "Contenu",
"{n} of {limit}": "{n} sur {limit}", "{n} of {limit}": "{n} sur {limit}",
"Limits": "Limites", "Limits": "Limites",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart refuse de créer au-delà dune limite. Un champ vide signifie aucune limite.", "The mail server refuses to create more than a limit allows. An empty field is no limit.": "Le serveur de messagerie refuse de créer plus que ce quune limite autorise. Un champ vide signifie aucune limite.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Le maximum autorisé à quiconque dans ce locataire : ses propres rôles sont réduits à ce que ceux-ci accordent. Seuls les rôles dont vous détenez vous-même les autorisations sont proposés.", "The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Le maximum autorisé à quiconque dans ce locataire : ses propres rôles sont réduits à ce que ceux-ci accordent. Seuls les rôles dont vous détenez vous-même les autorisations sont proposés.",
"Checking what is still in this tenant…": "Vérification de ce que contient encore ce locataire…", "Checking what is still in this tenant…": "Vérification de ce que contient encore ce locataire…",
"It still holds accounts, domains or other things. Move them out first.": "Il contient encore des comptes, des domaines ou dautres éléments. Déplacez-les dabord.", "It still holds accounts, domains or other things. Move them out first.": "Il contient encore des comptes, des domaines ou dautres éléments. Déplacez-les dabord.",
@@ -287,7 +287,6 @@ export const catalog: Catalog = {
"Still holds {things}. Move them out first.": "Contient encore {things}. Déplacez-les dabord.", "Still holds {things}. Move them out first.": "Contient encore {things}. Déplacez-les dabord.",
"Delete tenant": "Supprimer le locataire", "Delete tenant": "Supprimer le locataire",
"Separate organizations on one server, each with its own people, domains and limits.": "Des organisations distinctes sur un même serveur, chacune avec ses personnes, ses domaines et ses limites.", "Separate organizations on one server, each with its own people, domains and limits.": "Des organisations distinctes sur un même serveur, chacune avec ses personnes, ses domaines et ses limites.",
"Tenants are a Stalwart Enterprise feature.": "Les locataires sont une fonctionnalité de Stalwart Enterprise.",
"Search tenants": "Rechercher des locataires", "Search tenants": "Rechercher des locataires",
"No tenants match": "Aucun locataire ne correspond", "No tenants match": "Aucun locataire ne correspond",
"No tenants yet": "Aucun locataire pour linstant", "No tenants yet": "Aucun locataire pour linstant",
@@ -1148,14 +1147,14 @@ export const catalog: Catalog = {
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Votre serveur les remet directement à votre navigateur : elles arrivent donc sans onglet ihasmail ouvert, avec l'expéditeur et l'objet. Votre navigateur doit tout de même être en cours d'exécution — si vous le quittez complètement, les notifications attendent et arrivent à sa réouverture.", "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Votre serveur les remet directement à votre navigateur : elles arrivent donc sans onglet ihasmail ouvert, avec l'expéditeur et l'objet. Votre navigateur doit tout de même être en cours d'exécution — si vous le quittez complètement, les notifications attendent et arrivent à sa réouverture.",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Votre serveur peut réveiller ce navigateur, mais sans indiquer l'expéditeur ni l'objet. Votre navigateur doit tout de même être en cours d'exécution.", "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Votre serveur peut réveiller ce navigateur, mais sans indiquer l'expéditeur ni l'objet. Votre navigateur doit tout de même être en cours d'exécution.",
"This is what a new-mail notification looks like.": "Voici à quoi ressemble une notification de nouveau message.", "This is what a new-mail notification looks like.": "Voici à quoi ressemble une notification de nouveau message.",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Vous êtes connecté en tant que {user}. Votre mot de passe n'est jamais enregistré dans le navigateur ; le serveur le conserve chiffré, par session, pour dialoguer avec Stalwart.", "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.": "Vous êtes connecté en tant que {user}. Votre mot de passe nest jamais stocké dans le navigateur ; le serveur le conserve chiffré, par session, pour communiquer avec le serveur de messagerie.",
"App passwords are managed by your mail administrator.": "Les mots de passe d'application sont gérés par votre administrateur de messagerie.", "App passwords are managed by your mail administrator.": "Les mots de passe d'application sont gérés par votre administrateur de messagerie.",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Changer votre mot de passe déconnecte vos autres sessions webmail. Les mots de passe d'application continuent de fonctionner.", "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Changer votre mot de passe déconnecte vos autres sessions webmail. Les mots de passe d'application continuent de fonctionner.",
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "L'authentification à deux facteurs est activée sur ce compte. ihasmail ne sait pas encore vous connecter avec un code : la connexion sur un autre appareil nécessite donc un mot de passe d'application — ou vous pouvez désactiver l'authentification à deux facteurs ici.", "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "L'authentification à deux facteurs est activée sur ce compte. ihasmail ne sait pas encore vous connecter avec un code : la connexion sur un autre appareil nécessite donc un mot de passe d'application — ou vous pouvez désactiver l'authentification à deux facteurs ici.",
"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.": "Un mot de passe distinct pour une application ou un appareil, révocable indépendamment. Les mots de passe d'application contournent les codes à deux facteurs et fonctionnent donc dans les applications qui ne peuvent pas en demander.", "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.": "Un mot de passe distinct pour une application ou un appareil, révocable indépendamment. Les mots de passe d'application contournent les codes à deux facteurs et fonctionnent donc dans les applications qui ne peuvent pas en demander.",
"Copy it into {name} now — it isn't shown again.": "Copiez-le dans {name} maintenant — il ne sera plus affiché.", "Copy it into {name} now — it isn't shown again.": "Copiez-le dans {name} maintenant — il ne sera plus affiché.",
"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.": "Aucun autre utilisateur trouvé dans l'annuaire : personne de nouveau ne peut être ajouté. Les partages déjà en place sont listés ci-dessous et restent supprimables.", "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.": "Aucun autre utilisateur trouvé dans l'annuaire : personne de nouveau ne peut être ajouté. Les partages déjà en place sont listés ci-dessous et restent supprimables.",
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart ne communique pas son numéro de version aux clients de messagerie ; ihasmail indique donc l'édition lorsque le serveur en fournit une. ihasmail requiert la version 0.16 ou ultérieure, et la connexion refuse toute version antérieure.", "This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.": "Ce webmail fonctionne avec le serveur de messagerie INBUXA, et la connexion refuse un serveur qui noffre pas ce dont il a besoin.",
"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.": "Il {damage} : les règles qu'il contient ne peuvent donc être ni affichées ni modifiées — enregistrer ce qui est arrivé écraserait le reste. Rechargez la page pour réessayer. Vos règles sont toujours sur le serveur ; rien ici ne les a modifiées.", "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.": "Il {damage} : les règles qu'il contient ne peuvent donc être ni affichées ni modifiées — enregistrer ce qui est arrivé écraserait le reste. Rechargez la page pour réessayer. Vos règles sont toujours sur le serveur ; rien ici ne les a modifiées.",
"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).": "L'éditeur visuel de règles ne gère que les scripts qu'il a créés. Vous pouvez modifier le script dans l'onglet {tab}, ou repartir de zéro avec des règles (le script existant sera conservé mais désactivé).", "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).": "L'éditeur visuel de règles ne gère que les scripts qu'il a créés. Vous pouvez modifier le script dans l'onglet {tab}, ou repartir de zéro avec des règles (le script existant sera conservé mais désactivé).",
"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.": "Votre script de filtrage {damage} : il n'est arrivé que partiellement. Ajouter une règle écraserait l'ensemble par cette partie. Rechargez la page et réessayez.", "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.": "Votre script de filtrage {damage} : il n'est arrivé que partiellement. Ajouter une règle écraserait l'ensemble par cette partie. Rechargez la page et réessayez.",
@@ -1164,8 +1163,7 @@ export const catalog: Catalog = {
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Seules les langues dans lesquelles ihasmail a été traduit apparaissent ici : la liste s'allonge donc à mesure que les traductions arrivent, et non avant — une langue proposée sans textes derrière elle ferait prétendre à la page qu'elle est dans une langue qui n'est pas la sienne.", "Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Seules les langues dans lesquelles ihasmail a été traduit apparaissent ici : la liste s'allonge donc à mesure que les traductions arrivent, et non avant — une langue proposée sans textes derrière elle ferait prétendre à la page qu'elle est dans une langue qui n'est pas la sienne.",
"tell us about it": "signalez-le-nous", "tell us about it": "signalez-le-nous",
"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}.": "Cette traduction a été générée par une IA et n'a pas été relue par une personne de langue maternelle française ; elle est donc marquée Beta jusqu'à validation. Tout ce qui sonne faux mérite d'être signalé — {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}.": "Cette traduction a été générée par une IA et n'a pas été relue par une personne de langue maternelle française ; elle est donc marquée Beta jusqu'à validation. Tout ce qui sonne faux mérite d'être signalé — {report}.",
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "La version de ihasmail est la date du commit à partir duquel elle a été construite, suivie de l'origine de ce commit : {example} provient d'un commit daté du 30 août 2026 arrivé via la pull request 129. Un commit qui n'est pas passé par là porte à la place son SHA court — {sha}. La version ne dit délibérément rien de Stalwart ; ce dont cette build a besoin du serveur figure à la ligne ci-dessus.", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "La version de ihasmail est la date du commit à partir duquel elle a été construite, suivie de l'origine de ce commit : {example} provient d'un commit daté du 30 août 2026 arrivé via la pull request 129. Un commit qui n'est pas passé par là porte à la place son SHA court — {sha}. La version ne dit délibérément rien du serveur de messagerie ; ce dont cette build a besoin du serveur figure à la ligne ci-dessus.",
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nouveau message", "New message": "Nouveau message",
"Start a new message with what was shared?": "Commencer un nouveau message avec le contenu partagé ?", "Start a new message with what was shared?": "Commencer un nouveau message avec le contenu partagé ?",
@@ -1220,6 +1218,7 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Impossible denregistrer les filtres : {error}", "Could not save filters: {error}": "Impossible denregistrer les filtres : {error}",
"Could not send the receipt: {error}": "Impossible denvoyer laccusé de lecture : {error}", "Could not send the receipt: {error}": "Impossible denvoyer laccusé de lecture : {error}",
"Could not sign in.": "Connexion impossible.", "Could not sign in.": "Connexion impossible.",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Vous êtes connecté en tant que {user}. Ce webmail ne voit jamais votre mot de passe : il conserve un jeton de connexion de votre serveur de messagerie, chiffré par session.",
"About INBUXA webmail": "À propos dINBUXA webmail", "About INBUXA webmail": "À propos dINBUXA webmail",
"Mail server": "Serveur de messagerie", "Mail server": "Serveur de messagerie",
"You'll enter your password on your mail server's sign-in page.": "Vous saisirez votre mot de passe sur la page de connexion de votre serveur de messagerie.", "You'll enter your password on your mail server's sign-in page.": "Vous saisirez votre mot de passe sur la page de connexion de votre serveur de messagerie.",
+11 -12
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 Stalwart's own administration.": "詳しいメトリクス、配キュー、ログ、サーバー設定は Stalwart 自体の管理画面にあります。", "Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "詳細なメトリクス、配キュー、ログ、サーバー設定は INBUXA Admin にあります。",
"Open Stalwart admin": "Stalwart の管理画面を開く", "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,10 +208,10 @@ export const catalog: Catalog = {
"New role": "新しいロール", "New role": "新しいロール",
"This role carries permissions yours doesn't, so you can view it but not change it.": "このロールにはあなたのロールにない権限があるため、閲覧はできますが変更はできません。", "This role carries permissions yours doesn't, so you can view it but not change it.": "このロールにはあなたのロールにない権限があるため、閲覧はできますが変更はできません。",
"Your role lets you view roles but not change them.": "あなたのロールでは、ロールの閲覧はできますが変更はできません。", "Your role lets you view roles but not change them.": "あなたのロールでは、ロールの閲覧はできますが変更はできません。",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart はこのロールを既定で {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": "権限",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart が既定で付与するロールのため、削除できません。先に Stalwart 自体の管理画面で既定値を変更してください。", "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": "このロールを継承しています",
@@ -238,7 +238,7 @@ export const catalog: Catalog = {
"Delete role": "ロールを削除", "Delete role": "ロールを削除",
"It can't be undone.": "元に戻せません。", "It can't be undone.": "元に戻せません。",
"Type {name} to confirm": "確認のため {name} と入力してください", "Type {name} to confirm": "確認のため {name} と入力してください",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Stalwart の権限一覧を読み込めなかったため、ここでは権限を変更できません。{reason}", "The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "メールサーバーの権限一覧を読み込めなかったため、ここでは権限を変更できません。({reason})",
"Named sets of permissions, given to accounts, groups and tenants.": "アカウント、グループ、テナントに付与する、名前付きの権限のまとまりです。", "Named sets of permissions, given to accounts, groups and tenants.": "アカウント、グループ、テナントに付与する、名前付きの権限のまとまりです。",
"Search roles": "ロールを検索", "Search roles": "ロールを検索",
"No roles match": "一致するロールはありません", "No roles match": "一致するロールはありません",
@@ -261,11 +261,11 @@ export const catalog: Catalog = {
"{used} used": "{used} 使用中", "{used} used": "{used} 使用中",
"Your role lets you view tenants but not change them.": "あなたのロールでは、テナントの閲覧はできますが変更はできません。", "Your role lets you view tenants but not change them.": "あなたのロールでは、テナントの閲覧はできますが変更はできません。",
"Logo": "ロゴ", "Logo": "ロゴ",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "https アドレスまたは画像の data URL です。Stalwart はロゴを表示する場所でテナントの利用者に表示します。", "An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "画像の https アドレスまたはデータ URL。メールサーバーは、ロゴを表示する場所でテナントのユーザーにこれを表示します。",
"What it holds": "含まれるもの", "What it holds": "含まれるもの",
"{n} of {limit}": "{limit} 件中 {n} 件", "{n} of {limit}": "{limit} 件中 {n} 件",
"Limits": "上限", "Limits": "上限",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart は上限を超える作成を拒否します。空欄は上限なしす。", "The mail server refuses to create more than a limit allows. An empty field is no limit.": "メールサーバーは上限を超える作成を拒否します。空欄は上限なしを意味します。",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "このテナント内の誰にでも許可できる最大の範囲です。各自のロールは、これらが付与する範囲に絞られます。表示されるのは、あなた自身が権限を持つロールだけです。", "The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "このテナント内の誰にでも許可できる最大の範囲です。各自のロールは、これらが付与する範囲に絞られます。表示されるのは、あなた自身が権限を持つロールだけです。",
"Checking what is still in this tenant…": "このテナントに残っているものを確認しています…", "Checking what is still in this tenant…": "このテナントに残っているものを確認しています…",
"It still holds accounts, domains or other things. Move them out first.": "まだアカウント、ドメインなどが含まれています。先に移動してください。", "It still holds accounts, domains or other things. Move them out first.": "まだアカウント、ドメインなどが含まれています。先に移動してください。",
@@ -281,7 +281,6 @@ export const catalog: Catalog = {
"Still holds {things}. Move them out first.": "まだ {things} が含まれています。先に移動してください。", "Still holds {things}. Move them out first.": "まだ {things} が含まれています。先に移動してください。",
"Delete tenant": "テナントを削除", "Delete tenant": "テナントを削除",
"Separate organizations on one server, each with its own people, domains and limits.": "1 台のサーバー上の別々の組織で、それぞれに利用者、ドメイン、上限があります。", "Separate organizations on one server, each with its own people, domains and limits.": "1 台のサーバー上の別々の組織で、それぞれに利用者、ドメイン、上限があります。",
"Tenants are a Stalwart Enterprise feature.": "テナントは Stalwart Enterprise の機能です。",
"Search tenants": "テナントを検索", "Search tenants": "テナントを検索",
"No tenants match": "一致するテナントはありません", "No tenants match": "一致するテナントはありません",
"No tenants yet": "まだテナントがありません", "No tenants yet": "まだテナントがありません",
@@ -1096,16 +1095,15 @@ export const catalog: Catalog = {
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "メールサーバーが通知をブラウザーへ直接届けるため、ihasmail のタブを開いていなくても、差出人と件名つきで届きます。ただしブラウザーは起動している必要があります。完全に終了すると、通知は次に起動したときにまとめて届きます。", "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "メールサーバーが通知をブラウザーへ直接届けるため、ihasmail のタブを開いていなくても、差出人と件名つきで届きます。ただしブラウザーは起動している必要があります。完全に終了すると、通知は次に起動したときにまとめて届きます。",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "メールサーバーはこのブラウザーを呼び起こせますが、差出人や件名は含めません。ブラウザーは起動している必要があります。", "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "メールサーバーはこのブラウザーを呼び起こせますが、差出人や件名は含めません。ブラウザーは起動している必要があります。",
"This is what a new-mail notification looks like.": "新着メールの通知はこのように表示されます。", "This is what a new-mail notification looks like.": "新着メールの通知はこのように表示されます。",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "{user} としてサインインしています。パスワードがブラウザーに保存されることはありません。サーバーが Stalwart との通信のために、セッションごとに暗号化して保持します。", "You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to the mail server.": "{user} としてサインインしています。パスワードがブラウザーに保存されることはありません。メールサーバーとの通信用に、サーバーがセッションごとに暗号化して保持します。",
"App passwords are managed by your mail administrator.": "アプリパスワードはメール管理者が管理しています。", "App passwords are managed by your mail administrator.": "アプリパスワードはメール管理者が管理しています。",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "パスワードを変更すると、他のウェブメールのセッションはサインアウトされます。アプリパスワードはそのまま使えます。", "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "パスワードを変更すると、他のウェブメールのセッションはサインアウトされます。アプリパスワードはそのまま使えます。",
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "このアカウントでは 2 段階認証が有効です。ihasmail はまだ確認コードでのサインインに対応していないため、他のデバイスからサインインするにはアプリパスワードが必要です。ここで 2 段階認証をオフにすることもできます。", "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "このアカウントでは 2 段階認証が有効です。ihasmail はまだ確認コードでのサインインに対応していないため、他のデバイスからサインインするにはアプリパスワードが必要です。ここで 2 段階認証をオフにすることもできます。",
"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.": "メールアプリやデバイスごとに用意する別のパスワードで、単独で無効化できます。アプリパスワードは 2 段階認証の確認コードを省くため、コードを入力できないアプリでも使えます。", "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.": "メールアプリやデバイスごとに用意する別のパスワードで、単独で無効化できます。アプリパスワードは 2 段階認証の確認コードを省くため、コードを入力できないアプリでも使えます。",
"Copy it into {name} now — it isn't shown again.": "いま {name} にコピーしてください。二度と表示されません。", "Copy it into {name} now — it isn't shown again.": "いま {name} にコピーしてください。二度と表示されません。",
"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.": "ディレクトリに他のユーザーが見つからないため、新しく追加することはできません。すでに設定されている共有は下に表示され、解除はできます。", "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.": "ディレクトリに他のユーザーが見つからないため、新しく追加することはできません。すでに設定されている共有は下に表示され、解除はできます。",
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart はメールクライアントにバージョン番号を公開しないため、ihasmail はサーバーが示すエディションだけを表示します。ihasmail には 0.16 以降が必要で、それより古いサーバーへのサインインは拒否されます。", "This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.": "このウェブメールは INBUXA メールサーバーで動作し、必要な機能を提供しないサーバーへのサインインは拒否されます。",
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "ihasmail 自身のバージョンは、ビルド元となったコミットの日付と、そのコミットの出どころを並べたものです。{example} は 2026 年 8 月 30 日付のコミットから作られ、そのコミットはプルリクエスト 129 を通って届きました。プルリクエストを経ていないコミットは、代わりに短い SHA が付きます — {sha}。バージョンには Stalwart に関する情報をあえて含めていません。このビルドがサーバーに求めるものは、上の行に示されています。", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "ihasmail 自身のバージョンは、ビルド元となったコミットの日付と、そのコミットの出どころを並べたものです。{example} は 2026 年 8 月 30 日付のコミットから作られ、そのコミットはプルリクエスト 129 を通って届きました。プルリクエストを経ていないコミットは、代わりに短い SHA が付きます — {sha}。バージョンには メールサーバーに関する情報をあえて含めていません。このビルドがサーバーに求めるものは、上の行に示されています。",
// ── Constant labels ──────────────────────────────────────────────── // ── Constant labels ────────────────────────────────────────────────
"Add": "追加", "Add": "追加",
"Create subfolders": "サブフォルダーの作成", "Create subfolders": "サブフォルダーの作成",
@@ -1223,6 +1221,7 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "フィルターを保存できませんでした: {error}", "Could not save filters: {error}": "フィルターを保存できませんでした: {error}",
"Could not send the receipt: {error}": "開封確認を送信できませんでした: {error}", "Could not send the receipt: {error}": "開封確認を送信できませんでした: {error}",
"Could not sign in.": "サインインできませんでした。", "Could not sign in.": "サインインできませんでした。",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "{user} としてサインインしています。このウェブメールがパスワードを見ることはありません。メールサーバーから受け取ったサインイン用トークンを、セッションごとに暗号化して保持します。",
"About INBUXA webmail": "INBUXA ウェブメールについて", "About INBUXA webmail": "INBUXA ウェブメールについて",
"Mail server": "メールサーバー", "Mail server": "メールサーバー",
"You'll enter your password on your mail server's sign-in page.": "パスワードはメールサーバーのサインインページで入力します。", "You'll enter your password on your mail server's sign-in page.": "パスワードはメールサーバーのサインインページで入力します。",
+11 -12
View File
@@ -135,8 +135,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 Stalwart's own administration.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in de beheeromgeving van Stalwart.", "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 Stalwart admin": "Stalwart-beheer 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",
@@ -205,10 +205,10 @@ export const catalog: Catalog = {
"New role": "Nieuwe rol", "New role": "Nieuwe rol",
"This role carries permissions yours doesn't, so you can view it but not change it.": "Deze rol heeft rechten die uw rol niet heeft, dus u kunt hem bekijken maar niet wijzigen.", "This role carries permissions yours doesn't, so you can view it but not change it.": "Deze rol heeft rechten die uw rol niet heeft, dus u kunt hem bekijken maar niet wijzigen.",
"Your role lets you view roles but not change them.": "Met uw rol kunt u rollen bekijken, maar niet wijzigen.", "Your role lets you view roles but not change them.": "Met uw rol kunt u rollen bekijken, maar niet wijzigen.",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart 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",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in de beheeromgeving van Stalwart.", "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",
@@ -236,7 +236,7 @@ export const catalog: Catalog = {
"Delete role": "Rol verwijderen", "Delete role": "Rol verwijderen",
"It can't be undone.": "Dit kan niet ongedaan worden gemaakt.", "It can't be undone.": "Dit kan niet ongedaan worden gemaakt.",
"Type {name} to confirm": "Typ {name} om te bevestigen", "Type {name} to confirm": "Typ {name} om te bevestigen",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "De lijst met rechten van Stalwart kon niet worden geladen, dus rechten kunnen hier niet worden gewijzigd. ({reason})", "The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "De lijst met rechten van de mailserver kon niet worden geladen, dus rechten kunnen hier niet worden gewijzigd. ({reason})",
"Named sets of permissions, given to accounts, groups and tenants.": "Benoemde sets rechten, toegekend aan accounts, groepen en tenants.", "Named sets of permissions, given to accounts, groups and tenants.": "Benoemde sets rechten, toegekend aan accounts, groepen en tenants.",
"Search roles": "Rollen zoeken", "Search roles": "Rollen zoeken",
"No roles match": "Geen rollen gevonden", "No roles match": "Geen rollen gevonden",
@@ -259,11 +259,11 @@ export const catalog: Catalog = {
"{used} used": "{used} gebruikt", "{used} used": "{used} gebruikt",
"Your role lets you view tenants but not change them.": "Met uw rol kunt u tenants bekijken, maar niet wijzigen.", "Your role lets you view tenants but not change them.": "Met uw rol kunt u tenants bekijken, maar niet wijzigen.",
"Logo": "Logo", "Logo": "Logo",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Een https-adres of een data-URL van een afbeelding. Stalwart toont het aan de mensen van de tenant waar het een logo toont.", "An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Een https-adres of een data-URL van een afbeelding. De mailserver toont het aan de mensen van de tenant waar hij een logo toont.",
"What it holds": "Inhoud", "What it holds": "Inhoud",
"{n} of {limit}": "{n} van {limit}", "{n} of {limit}": "{n} van {limit}",
"Limits": "Limieten", "Limits": "Limieten",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart weigert meer aan te maken dan een limiet toestaat. Een leeg veld betekent geen limiet.", "The mail server refuses to create more than a limit allows. An empty field is no limit.": "De mailserver weigert meer aan te maken dan een limiet toestaat. Een leeg veld betekent geen limiet.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Het meeste dat iemand in deze tenant kan worden toegestaan: hun eigen rollen worden beperkt tot wat deze toekennen. Alleen rollen waarvan u zelf de rechten hebt worden aangeboden.", "The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Het meeste dat iemand in deze tenant kan worden toegestaan: hun eigen rollen worden beperkt tot wat deze toekennen. Alleen rollen waarvan u zelf de rechten hebt worden aangeboden.",
"Checking what is still in this tenant…": "Nagaan wat er nog in deze tenant zit…", "Checking what is still in this tenant…": "Nagaan wat er nog in deze tenant zit…",
"It still holds accounts, domains or other things. Move them out first.": "Er zitten nog accounts, domeinen of andere dingen in. Verplaats die eerst.", "It still holds accounts, domains or other things. Move them out first.": "Er zitten nog accounts, domeinen of andere dingen in. Verplaats die eerst.",
@@ -279,7 +279,6 @@ export const catalog: Catalog = {
"Still holds {things}. Move them out first.": "Bevat nog {things}. Verplaats die eerst.", "Still holds {things}. Move them out first.": "Bevat nog {things}. Verplaats die eerst.",
"Delete tenant": "Tenant verwijderen", "Delete tenant": "Tenant verwijderen",
"Separate organizations on one server, each with its own people, domains and limits.": "Afzonderlijke organisaties op één server, elk met eigen mensen, domeinen en limieten.", "Separate organizations on one server, each with its own people, domains and limits.": "Afzonderlijke organisaties op één server, elk met eigen mensen, domeinen en limieten.",
"Tenants are a Stalwart Enterprise feature.": "Tenants zijn een functie van Stalwart Enterprise.",
"Search tenants": "Tenants zoeken", "Search tenants": "Tenants zoeken",
"No tenants match": "Geen tenants gevonden", "No tenants match": "Geen tenants gevonden",
"No tenants yet": "Nog geen tenants", "No tenants yet": "Nog geen tenants",
@@ -1141,14 +1140,14 @@ export const catalog: Catalog = {
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — 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 ihasmail-tabblad, met afzender en onderwerp erbij. Uw browser moet wel draaien — 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 ihasmail tab open, naming the sender and subject. Your browser still has to be running — 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 ihasmail-tabblad, met afzender en onderwerp erbij. Uw browser moet wel draaien — 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 wekken, maar vermeldt geen afzender of onderwerp. Uw browser moet wel draaien.",
"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 post 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 Stalwart.": "U bent ingelogd als {user}. Uw wachtwoord wordt nooit in de browser opgeslagen; de server bewaart het versleuteld per sessie om met Stalwart 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 uitgelogd. App-wachtwoorden blijven werken.",
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Voor dit account staat tweefactorauthenticatie aan. ihasmail kan u nog niet met een code inloggen, dus inloggen op een ander apparaat vereist een app-wachtwoord — of u schakelt tweefactorauthenticatie hier uit.", "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Voor dit account staat tweefactorauthenticatie aan. ihasmail kan u nog niet met een code inloggen, dus inloggen op een ander apparaat vereist een app-wachtwoord — 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-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.",
"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.": "Geen andere gebruikers gevonden in de directory, dus er kan niemand nieuws worden toegevoegd. Bestaande gedeelde items staan hieronder en kunnen nog worden verwijderd.",
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart geeft zijn versienummer niet door aan e-mailprogramma's, dus ihasmail noemt de editie als de server die opgeeft. ihasmail vereist 0.16 of nieuwer; inloggen weigert alles wat ouder is.", "This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.": "Deze webmail werkt met de INBUXA-mailserver, en aanmelden weigert een server die niet biedt wat nodig is.",
"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}, 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.",
"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 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).",
"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 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.",
@@ -1157,8 +1156,7 @@ export const catalog: Catalog = {
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — 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 ihasmail is vertaald; de lijst groeit dus mee met de vertalingen en niet erop vooruit — 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 ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — 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 ihasmail is vertaald; de lijst groeit dus mee met de vertalingen en niet erop vooruit — een taal die wordt aangeboden zonder teksten erachter zou de pagina laten beweren dat ze in een taal is die ze niet 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}.",
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "De eigen versie van ihasmail 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 — {sha}. De versie zegt bewust niets over Stalwart; wat deze build van de server nodig heeft, staat op de regel hierboven.", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "De eigen versie van ihasmail 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 — {sha}. De versie zegt bewust niets over de mailserver; wat deze build van de server nodig heeft, staat op de regel hierboven.",
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nieuw bericht", "New message": "Nieuw bericht",
"Start a new message with what was shared?": "Een nieuw bericht beginnen met wat is gedeeld?", "Start a new message with what was shared?": "Een nieuw bericht beginnen met wat is gedeeld?",
@@ -1213,6 +1211,7 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Filters opslaan mislukt: {error}", "Could not save filters: {error}": "Filters opslaan mislukt: {error}",
"Could not send the receipt: {error}": "De leesbevestiging kon niet worden verzonden: {error}", "Could not send the receipt: {error}": "De leesbevestiging kon niet worden verzonden: {error}",
"Could not sign in.": "Aanmelden mislukt.", "Could not sign in.": "Aanmelden mislukt.",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "U bent ingelogd als {user}. Deze webmail ziet uw wachtwoord nooit: hij bewaart een aanmeldtoken van uw mailserver, versleuteld per sessie.",
"About INBUXA webmail": "Over INBUXA webmail", "About INBUXA webmail": "Over INBUXA webmail",
"Mail server": "Mailserver", "Mail server": "Mailserver",
"You'll enter your password on your mail server's sign-in page.": "U voert uw wachtwoord in op de aanmeldpagina van uw mailserver.", "You'll enter your password on your mail server's sign-in page.": "U voert uw wachtwoord in op de aanmeldpagina van uw mailserver.",
+11 -12
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 Stalwart's own administration.": "Métricas detalhadas, a fila de entrega, os logs e as configurações do servidor ficam na administração do próprio Stalwart.", "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 Stalwart admin": "Abrir a administração do Stalwart", "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",
@@ -212,10 +212,10 @@ export const catalog: Catalog = {
"New role": "Nova função", "New role": "Nova função",
"This role carries permissions yours doesn't, so you can view it but not change it.": "Esta função tem permissões que a sua não tem, então você pode vê-la, mas não alterá-la.", "This role carries permissions yours doesn't, so you can view it but not change it.": "Esta função tem permissões que a sua não tem, então você pode vê-la, mas não alterá-la.",
"Your role lets you view roles but not change them.": "Sua função permite ver as funções, mas não alterá-las.", "Your role lets you view roles but not change them.": "Sua função permite ver as funções, mas não alterá-las.",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "O Stalwart atribui esta função por padrão a {kinds}. Uma alteração aqui afeta 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",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "O Stalwart atribui esta função por padrão, então ela não pode ser excluída. Altere primeiro os padrões na administração do próprio Stalwart.", "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",
@@ -242,7 +242,7 @@ export const catalog: Catalog = {
"Delete role": "Excluir função", "Delete role": "Excluir função",
"It can't be undone.": "Isso não pode ser desfeito.", "It can't be undone.": "Isso não pode ser desfeito.",
"Type {name} to confirm": "Digite {name} para confirmar", "Type {name} to confirm": "Digite {name} para confirmar",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Não foi possível carregar a lista de permissões do Stalwart, então as permissões não podem ser alteradas aqui. ({reason})", "The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Não foi possível carregar a lista de permissões do servidor de e-mail, então as permissões não podem ser alteradas aqui. ({reason})",
"Named sets of permissions, given to accounts, groups and tenants.": "Conjuntos nomeados de permissões, atribuídos a contas, grupos e locatários.", "Named sets of permissions, given to accounts, groups and tenants.": "Conjuntos nomeados de permissões, atribuídos a contas, grupos e locatários.",
"Search roles": "Pesquisar funções", "Search roles": "Pesquisar funções",
"No roles match": "Nenhuma função corresponde", "No roles match": "Nenhuma função corresponde",
@@ -265,11 +265,11 @@ export const catalog: Catalog = {
"{used} used": "{used} usados", "{used} used": "{used} usados",
"Your role lets you view tenants but not change them.": "Sua função permite ver os locatários, mas não alterá-los.", "Your role lets you view tenants but not change them.": "Sua função permite ver os locatários, mas não alterá-los.",
"Logo": "Logotipo", "Logo": "Logotipo",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Um endereço https ou uma URL data de uma imagem. O Stalwart a mostra às pessoas do locatário onde mostra um logotipo.", "An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Um endereço https ou uma URL de dados de uma imagem. O servidor de e-mail a mostra às pessoas do locatário onde mostra um logotipo.",
"What it holds": "O que contém", "What it holds": "O que contém",
"{n} of {limit}": "{n} de {limit}", "{n} of {limit}": "{n} de {limit}",
"Limits": "Limites", "Limits": "Limites",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "O Stalwart se recusa a criar mais do que um limite permite. Um campo vazio significa sem limite.", "The mail server refuses to create more than a limit allows. An empty field is no limit.": "O servidor de e-mail se recusa a criar mais do que um limite permite. Um campo vazio significa sem limite.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "O máximo que alguém neste locatário pode ter: as funções próprias são reduzidas ao que estas concedem. Só são oferecidas funções cujas permissões você mesmo tem.", "The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "O máximo que alguém neste locatário pode ter: as funções próprias são reduzidas ao que estas concedem. Só são oferecidas funções cujas permissões você mesmo tem.",
"Checking what is still in this tenant…": "Verificando o que ainda há neste locatário…", "Checking what is still in this tenant…": "Verificando o que ainda há neste locatário…",
"It still holds accounts, domains or other things. Move them out first.": "Ele ainda tem contas, domínios ou outros itens. Mova-os primeiro.", "It still holds accounts, domains or other things. Move them out first.": "Ele ainda tem contas, domínios ou outros itens. Mova-os primeiro.",
@@ -285,7 +285,6 @@ export const catalog: Catalog = {
"Still holds {things}. Move them out first.": "Ainda tem {things}. Mova-os primeiro.", "Still holds {things}. Move them out first.": "Ainda tem {things}. Mova-os primeiro.",
"Delete tenant": "Excluir locatário", "Delete tenant": "Excluir locatário",
"Separate organizations on one server, each with its own people, domains and limits.": "Organizações separadas em um mesmo servidor, cada uma com suas próprias pessoas, domínios e limites.", "Separate organizations on one server, each with its own people, domains and limits.": "Organizações separadas em um mesmo servidor, cada uma com suas próprias pessoas, domínios e limites.",
"Tenants are a Stalwart Enterprise feature.": "Locatários são um recurso do Stalwart Enterprise.",
"Search tenants": "Pesquisar locatários", "Search tenants": "Pesquisar locatários",
"No tenants match": "Nenhum locatário corresponde", "No tenants match": "Nenhum locatário corresponde",
"No tenants yet": "Nenhum locatário ainda", "No tenants yet": "Nenhum locatário ainda",
@@ -1146,14 +1145,14 @@ export const catalog: Catalog = {
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Seu servidor de e-mail as entrega direto ao navegador, então elas chegam sem nenhuma aba do ihasmail aberta, com o remetente e o assunto. Mesmo assim o navegador precisa estar em execução — se você fechá-lo por completo, as notificações esperam e chegam quando você abri-lo de novo.", "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Seu servidor de e-mail as entrega direto ao navegador, então elas chegam sem nenhuma aba do ihasmail aberta, com o remetente e o assunto. Mesmo assim o navegador precisa estar em execução — se você fechá-lo por completo, as notificações esperam e chegam quando você abri-lo de novo.",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Seu servidor de e-mail consegue acordar este navegador, mas não informa o remetente nem o assunto. Mesmo assim o navegador precisa estar em execução.", "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Seu servidor de e-mail consegue acordar este navegador, mas não informa o remetente nem o assunto. Mesmo assim o navegador precisa estar em execução.",
"This is what a new-mail notification looks like.": "É assim que uma notificação de e-mail novo aparece.", "This is what a new-mail notification looks like.": "É assim que uma notificação de e-mail novo aparece.",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Você está conectado como {user}. Sua senha nunca é guardada no navegador; o servidor a mantém criptografada por sessão para falar com o Stalwart.", "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.": "Você entrou como {user}. Sua senha nunca é guardada no navegador; o servidor a mantém criptografada por sessão para se comunicar com o servidor de e-mail.",
"App passwords are managed by your mail administrator.": "As senhas de aplicativo são gerenciadas pelo seu administrador de e-mail.", "App passwords are managed by your mail administrator.": "As senhas de aplicativo são gerenciadas pelo seu administrador de e-mail.",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Mudar sua senha encerra suas outras sessões de webmail. As senhas de aplicativo continuam funcionando.", "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Mudar sua senha encerra suas outras sessões de webmail. As senhas de aplicativo continuam funcionando.",
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Esta conta está com a autenticação em duas etapas ativada. O ihasmail ainda não consegue conectar você com um código, então entrar em outro dispositivo exige uma senha de aplicativo — ou você pode desativar a autenticação em duas etapas aqui.", "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Esta conta está com a autenticação em duas etapas ativada. O ihasmail ainda não consegue conectar você com um código, então entrar em outro dispositivo exige uma senha de aplicativo — ou você pode desativar a autenticação em duas etapas aqui.",
"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.": "Uma senha separada para um aplicativo de e-mail ou dispositivo, que você pode revogar sozinha. As senhas de aplicativo dispensam os códigos de duas etapas, então continuam funcionando em aplicativos que não conseguem pedir um.", "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.": "Uma senha separada para um aplicativo de e-mail ou dispositivo, que você pode revogar sozinha. As senhas de aplicativo dispensam os códigos de duas etapas, então continuam funcionando em aplicativos que não conseguem pedir um.",
"Copy it into {name} now — it isn't shown again.": "Copie-a para {name} agora — ela não será mostrada de novo.", "Copy it into {name} now — it isn't shown again.": "Copie-a para {name} agora — ela não será mostrada de novo.",
"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.": "Nenhum outro usuário encontrado no diretório, então ninguém novo pode ser adicionado. O que já está compartilhado aparece abaixo e ainda pode ser removido.", "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.": "Nenhum outro usuário encontrado no diretório, então ninguém novo pode ser adicionado. O que já está compartilhado aparece abaixo e ainda pode ser removido.",
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "O Stalwart não informa seu número de versão aos clientes de e-mail, então o ihasmail indica a edição quando o servidor fornece uma. O ihasmail exige a versão 0.16 ou mais recente, e o login recusa qualquer versão anterior.", "This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.": "Este webmail funciona com o servidor de e-mail INBUXA, e a entrada recusa um servidor que não ofereça o que ele precisa.",
"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.": "Ele {damage}, então as regras nele não podem ser mostradas nem editadas — salvar o que chegou sobrescreveria o resto. Recarregue a página para tentar de novo. Suas regras continuam no servidor; nada aqui as alterou.", "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.": "Ele {damage}, então as regras nele não podem ser mostradas nem editadas — salvar o que chegou sobrescreveria o resto. Recarregue a página para tentar de novo. Suas regras continuam no servidor; nada aqui as alterou.",
"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).": "O editor visual de regras só gerencia os scripts que ele mesmo criou. Você pode editar o script na aba {tab}, ou começar do zero com regras (o script existente será mantido, mas desativado).", "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).": "O editor visual de regras só gerencia os scripts que ele mesmo criou. Você pode editar o script na aba {tab}, ou começar do zero com regras (o script existente será mantido, mas desativado).",
"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.": "Seu script de filtragem {damage}, então só parte dele chegou. Adicionar uma regra escreveria essa parte por cima do todo. Recarregue a página e tente de novo.", "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.": "Seu script de filtragem {damage}, então só parte dele chegou. Adicionar uma regra escreveria essa parte por cima do todo. Recarregue a página e tente de novo.",
@@ -1162,8 +1161,7 @@ export const catalog: Catalog = {
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Aqui aparecem só os idiomas para os quais o ihasmail foi traduzido, então a lista cresce conforme as traduções chegam, e não antes — um idioma oferecido sem textos por trás faria a página afirmar estar em um idioma que não é o dela.", "Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Aqui aparecem só os idiomas para os quais o ihasmail foi traduzido, então a lista cresce conforme as traduções chegam, e não antes — um idioma oferecido sem textos por trás faria a página afirmar estar em um idioma que não é o dela.",
"tell us about it": "conte para nós", "tell us about it": "conte para nós",
"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}.": "Esta tradução foi gerada por IA e não foi revisada por uma pessoa nativa, então está marcada como Beta até que alguém a aprove. Tudo o que soar errado vale um aviso — {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}.": "Esta tradução foi gerada por IA e não foi revisada por uma pessoa nativa, então está marcada como Beta até que alguém a aprove. Tudo o que soar errado vale um aviso — {report}.",
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "A versão do próprio ihasmail é a data do commit a partir do qual ele foi compilado, seguida da origem desse commit: {example} foi compilado a partir de um commit de 30 de agosto de 2026 que veio pela pull request 129. Um commit que não veio por uma delas carrega no lugar o SHA curto — {sha}. A versão não diz nada sobre o Stalwart de propósito; o que esta compilação precisa do servidor está na linha acima.", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "A versão do próprio ihasmail é a data do commit a partir do qual ele foi compilado, seguida da origem desse commit: {example} foi compilado a partir de um commit de 30 de agosto de 2026 que veio pela pull request 129. Um commit que não veio por uma delas carrega no lugar o SHA curto — {sha}. A versão não diz nada sobre o servidor de e-mail de propósito; o que esta compilação precisa do servidor está na linha acima.",
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nova mensagem", "New message": "Nova mensagem",
"Start a new message with what was shared?": "Iniciar uma nova mensagem com o que foi compartilhado?", "Start a new message with what was shared?": "Iniciar uma nova mensagem com o que foi compartilhado?",
@@ -1218,6 +1216,7 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Não foi possível salvar os filtros: {error}", "Could not save filters: {error}": "Não foi possível salvar os filtros: {error}",
"Could not send the receipt: {error}": "Não foi possível enviar a confirmação de leitura: {error}", "Could not send the receipt: {error}": "Não foi possível enviar a confirmação de leitura: {error}",
"Could not sign in.": "Não foi possível entrar.", "Could not sign in.": "Não foi possível entrar.",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Você entrou como {user}. Este webmail nunca vê sua senha: ele guarda um token de entrada do seu servidor de e-mail, criptografado por sessão.",
"About INBUXA webmail": "Sobre o INBUXA webmail", "About INBUXA webmail": "Sobre o INBUXA webmail",
"Mail server": "Servidor de e-mail", "Mail server": "Servidor de e-mail",
"You'll enter your password on your mail server's sign-in page.": "Você vai digitar sua senha na página de entrada do seu servidor de e-mail.", "You'll enter your password on your mail server's sign-in page.": "Você vai digitar sua senha na página de entrada do seu servidor de e-mail.",
+11 -12
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 Stalwart's own administration.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в собственной панели администрирования Stalwart.", "Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в INBUXA Admin.",
"Open Stalwart admin": "Открыть администрирование Stalwart", "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,10 +211,10 @@ export const catalog: Catalog = {
"New role": "Новая роль", "New role": "Новая роль",
"This role carries permissions yours doesn't, so you can view it but not change it.": "У этой роли есть разрешения, которых нет у вашей, поэтому её можно просматривать, но не изменять.", "This role carries permissions yours doesn't, so you can view it but not change it.": "У этой роли есть разрешения, которых нет у вашей, поэтому её можно просматривать, но не изменять.",
"Your role lets you view roles but not change them.": "Ваша роль позволяет просматривать роли, но не изменять их.", "Your role lets you view roles but not change them.": "Ваша роль позволяет просматривать роли, но не изменять их.",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart по умолчанию выдаёт эту роль {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": "Разрешения",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart выдаёт эту роль по умолчанию, поэтому её нельзя удалить. Сначала измените значения по умолчанию в собственной панели администрирования Stalwart.", "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": "основана на этой",
@@ -241,7 +241,7 @@ export const catalog: Catalog = {
"Delete role": "Удалить роль", "Delete role": "Удалить роль",
"It can't be undone.": "Это нельзя отменить.", "It can't be undone.": "Это нельзя отменить.",
"Type {name} to confirm": "Введите {name} для подтверждения", "Type {name} to confirm": "Введите {name} для подтверждения",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Не удалось загрузить список разрешений Stalwart, поэтому изменить разрешения здесь нельзя. ({reason})", "The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Не удалось загрузить список разрешений почтового сервера, поэтому здесь нельзя изменить разрешения. ({reason})",
"Named sets of permissions, given to accounts, groups and tenants.": "Именованные наборы разрешений для учётных записей, групп и арендаторов.", "Named sets of permissions, given to accounts, groups and tenants.": "Именованные наборы разрешений для учётных записей, групп и арендаторов.",
"Search roles": "Поиск ролей", "Search roles": "Поиск ролей",
"No roles match": "Нет подходящих ролей", "No roles match": "Нет подходящих ролей",
@@ -264,11 +264,11 @@ export const catalog: Catalog = {
"{used} used": "Занято {used}", "{used} used": "Занято {used}",
"Your role lets you view tenants but not change them.": "Ваша роль позволяет просматривать арендаторов, но не изменять их.", "Your role lets you view tenants but not change them.": "Ваша роль позволяет просматривать арендаторов, но не изменять их.",
"Logo": "Логотип", "Logo": "Логотип",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Адрес https или data-URL изображения. Stalwart показывает его людям арендатора там, где показывает логотип.", "An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Адрес https или data-URL изображения. Почтовый сервер показывает его людям арендатора там, где показывает логотип.",
"What it holds": "Содержимое", "What it holds": "Содержимое",
"{n} of {limit}": "{n} из {limit}", "{n} of {limit}": "{n} из {limit}",
"Limits": "Лимиты", "Limits": "Лимиты",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart не даёт создать больше, чем позволяет лимит. Пустое поле — без лимита.", "The mail server refuses to create more than a limit allows. An empty field is no limit.": "Почтовый сервер не позволяет создать больше, чем разрешает лимит. Пустое поле означает отсутствие лимита.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, что может быть разрешено любому в этом арендаторе: их собственные роли урезаются до того, что дают эти. Предлагаются только роли, разрешения которых есть у вас самих.", "The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, что может быть разрешено любому в этом арендаторе: их собственные роли урезаются до того, что дают эти. Предлагаются только роли, разрешения которых есть у вас самих.",
"Checking what is still in this tenant…": "Проверка того, что ещё есть у этого арендатора…", "Checking what is still in this tenant…": "Проверка того, что ещё есть у этого арендатора…",
"It still holds accounts, domains or other things. Move them out first.": "У него ещё есть учётные записи, домены или что-то другое. Сначала перенесите их.", "It still holds accounts, domains or other things. Move them out first.": "У него ещё есть учётные записи, домены или что-то другое. Сначала перенесите их.",
@@ -284,7 +284,6 @@ export const catalog: Catalog = {
"Still holds {things}. Move them out first.": "Ещё содержит: {things}. Сначала перенесите их.", "Still holds {things}. Move them out first.": "Ещё содержит: {things}. Сначала перенесите их.",
"Delete tenant": "Удалить арендатора", "Delete tenant": "Удалить арендатора",
"Separate organizations on one server, each with its own people, domains and limits.": "Отдельные организации на одном сервере, у каждой свои люди, домены и лимиты.", "Separate organizations on one server, each with its own people, domains and limits.": "Отдельные организации на одном сервере, у каждой свои люди, домены и лимиты.",
"Tenants are a Stalwart Enterprise feature.": "Арендаторы — функция Stalwart Enterprise.",
"Search tenants": "Поиск арендаторов", "Search tenants": "Поиск арендаторов",
"No tenants match": "Нет подходящих арендаторов", "No tenants match": "Нет подходящих арендаторов",
"No tenants yet": "Арендаторов пока нет", "No tenants yet": "Арендаторов пока нет",
@@ -1145,14 +1144,14 @@ export const catalog: Catalog = {
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Почтовый сервер доставляет их прямо в браузер, поэтому они приходят без открытой вкладки ihasmail и содержат отправителя и тему. Браузер при этом должен быть запущен: если закрыть его полностью, уведомления подождут и придут при следующем запуске.", "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Почтовый сервер доставляет их прямо в браузер, поэтому они приходят без открытой вкладки ihasmail и содержат отправителя и тему. Браузер при этом должен быть запущен: если закрыть его полностью, уведомления подождут и придут при следующем запуске.",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Почтовый сервер может разбудить этот браузер, но не сообщит отправителя и тему. Браузер при этом должен быть запущен.", "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Почтовый сервер может разбудить этот браузер, но не сообщит отправителя и тему. Браузер при этом должен быть запущен.",
"This is what a new-mail notification looks like.": "Так выглядит уведомление о новом письме.", "This is what a new-mail notification looks like.": "Так выглядит уведомление о новом письме.",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Вы вошли как {user}. Пароль никогда не хранится в браузере: сервер держит его в зашифрованном виде на время сеанса, чтобы общаться со Stalwart.", "You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to the mail server.": "Вы вошли как {user}. Пароль никогда не хранится в браузере; сервер хранит его в зашифрованном виде для каждого сеанса, чтобы обращаться к почтовому серверу.",
"App passwords are managed by your mail administrator.": "Паролями приложений управляет ваш почтовый администратор.", "App passwords are managed by your mail administrator.": "Паролями приложений управляет ваш почтовый администратор.",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Смена пароля завершает остальные сеансы веб-почты. Пароли приложений продолжают работать.", "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Смена пароля завершает остальные сеансы веб-почты. Пароли приложений продолжают работать.",
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Для этой учётной записи включена двухфакторная аутентификация. ihasmail пока не умеет входить по коду, поэтому для входа на другом устройстве нужен пароль приложения — либо двухфакторную аутентификацию можно отключить здесь.", "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Для этой учётной записи включена двухфакторная аутентификация. ihasmail пока не умеет входить по коду, поэтому для входа на другом устройстве нужен пароль приложения — либо двухфакторную аутентификацию можно отключить здесь.",
"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.": "Отдельный пароль для почтовой программы или устройства, который можно отозвать по отдельности. Пароли приложений обходят двухфакторные коды и поэтому работают там, где запросить код невозможно.", "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.": "Отдельный пароль для почтовой программы или устройства, который можно отозвать по отдельности. Пароли приложений обходят двухфакторные коды и поэтому работают там, где запросить код невозможно.",
"Copy it into {name} now — it isn't shown again.": "Скопируйте его в {name} сейчас — больше он не показывается.", "Copy it into {name} now — it isn't shown again.": "Скопируйте его в {name} сейчас — больше он не показывается.",
"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.": "В каталоге не найдено других пользователей, поэтому добавить некого. Уже открытый доступ перечислен ниже, и его по-прежнему можно закрыть.", "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.": "В каталоге не найдено других пользователей, поэтому добавить некого. Уже открытый доступ перечислен ниже, и его по-прежнему можно закрыть.",
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart не сообщает почтовым клиентам номер версии, поэтому ihasmail показывает редакцию, если сервер её называет. ihasmail требует версию 0.16 или новее, и вход с более старой не выполняется.", "This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.": "Эта веб-почта работает с почтовым сервером INBUXA, а вход отклоняет сервер, который не предоставляет нужного.",
"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.": "Он {damage}, поэтому правила в нём нельзя показать или изменить: сохранение полученной части затёрло бы остальное. Перезагрузите страницу и попробуйте снова. Ваши правила остаются на сервере, здесь их ничто не меняло.", "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.": "Он {damage}, поэтому правила в нём нельзя показать или изменить: сохранение полученной части затёрло бы остальное. Перезагрузите страницу и попробуйте снова. Ваши правила остаются на сервере, здесь их ничто не меняло.",
"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).": "Визуальный редактор правил работает только со скриптами, которые создал сам. Скрипт можно изменить на вкладке {tab} или начать заново с правил (существующий скрипт сохранится, но будет отключён).", "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).": "Визуальный редактор правил работает только со скриптами, которые создал сам. Скрипт можно изменить на вкладке {tab} или начать заново с правил (существующий скрипт сохранится, но будет отключён).",
"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.": "Ваш скрипт фильтрации {damage}, поэтому получена только его часть. Добавление правила затёрло бы этой частью весь скрипт. Перезагрузите страницу и попробуйте снова.", "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.": "Ваш скрипт фильтрации {damage}, поэтому получена только его часть. Добавление правила затёрло бы этой частью весь скрипт. Перезагрузите страницу и попробуйте снова.",
@@ -1161,8 +1160,7 @@ export const catalog: Catalog = {
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Здесь показаны только языки, на которые ihasmail переведён, поэтому список растёт вместе с переводами, а не опережает их: язык без текстов заставил бы страницу утверждать, что она написана на языке, которым не является.", "Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Здесь показаны только языки, на которые ihasmail переведён, поэтому список растёт вместе с переводами, а не опережает их: язык без текстов заставил бы страницу утверждать, что она написана на языке, которым не является.",
"tell us about it": "сообщите нам", "tell us about it": "сообщите нам",
"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}.": "Этот перевод сделан ИИ и не проверен носителем языка, поэтому помечен как Beta до тех пор, пока кто-нибудь его не подтвердит. Обо всём, что звучит неправильно, стоит сообщить — {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}.": "Этот перевод сделан ИИ и не проверен носителем языка, поэтому помечен как Beta до тех пор, пока кто-нибудь его не подтвердит. Обо всём, что звучит неправильно, стоит сообщить — {report}.",
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "Собственная версия ihasmail — это дата коммита, из которого он собран, и указание, откуда этот коммит взялся: {example} собран из коммита от 30 августа 2026 года, пришедшего через pull request 129. Коммит, пришедший иначе, несёт вместо этого короткий SHA — {sha}. Версия намеренно ничего не сообщает о Stalwart; то, что этой сборке нужно от сервера, указано строкой выше.", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "Собственная версия ihasmail — это дата коммита, из которого он собран, и указание, откуда этот коммит взялся: {example} собран из коммита от 30 августа 2026 года, пришедшего через pull request 129. Коммит, пришедший иначе, несёт вместо этого короткий SHA — {sha}. Версия намеренно ничего не сообщает о почтовом сервере; то, что этой сборке нужно от сервера, указано строкой выше.",
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Новое письмо", "New message": "Новое письмо",
"Start a new message with what was shared?": "Начать новое письмо с полученным содержимым?", "Start a new message with what was shared?": "Начать новое письмо с полученным содержимым?",
@@ -1217,6 +1215,7 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Не удалось сохранить фильтры: {error}", "Could not save filters: {error}": "Не удалось сохранить фильтры: {error}",
"Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}", "Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}",
"Could not sign in.": "Не удалось войти.", "Could not sign in.": "Не удалось войти.",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Вы вошли как {user}. Эта веб-почта никогда не видит ваш пароль: она хранит токен входа от вашего почтового сервера, зашифрованный для каждого сеанса.",
"About INBUXA webmail": "О веб-почте INBUXA", "About INBUXA webmail": "О веб-почте INBUXA",
"Mail server": "Почтовый сервер", "Mail server": "Почтовый сервер",
"You'll enter your password on your mail server's sign-in page.": "Пароль вводится на странице входа вашего почтового сервера.", "You'll enter your password on your mail server's sign-in page.": "Пароль вводится на странице входа вашего почтового сервера.",
+11 -12
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 Stalwart's own administration.": "Докладні метрики, черга доставки, журнали та налаштування сервера є у власній панелі адміністрування Stalwart.", "Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в INBUXA Admin.",
"Open Stalwart admin": "Відкрити адміністрування Stalwart", "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": "Нова група",
@@ -205,10 +205,10 @@ export const catalog: Catalog = {
"New role": "Нова роль", "New role": "Нова роль",
"This role carries permissions yours doesn't, so you can view it but not change it.": "Ця роль має дозволи, яких немає у вашої, тому її можна переглядати, але не змінювати.", "This role carries permissions yours doesn't, so you can view it but not change it.": "Ця роль має дозволи, яких немає у вашої, тому її можна переглядати, але не змінювати.",
"Your role lets you view roles but not change them.": "Ваша роль дозволяє переглядати ролі, але не змінювати їх.", "Your role lets you view roles but not change them.": "Ваша роль дозволяє переглядати ролі, але не змінювати їх.",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart за замовчуванням надає цю роль {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": "Дозволи",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart надає цю роль за замовчуванням, тому її не можна видалити. Спершу змініть значення за замовчуванням у власній панелі адміністрування Stalwart.", "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": "базується на цій",
@@ -235,7 +235,7 @@ export const catalog: Catalog = {
"Delete role": "Видалити роль", "Delete role": "Видалити роль",
"It can't be undone.": "Це не можна скасувати.", "It can't be undone.": "Це не можна скасувати.",
"Type {name} to confirm": "Введіть {name} для підтвердження", "Type {name} to confirm": "Введіть {name} для підтвердження",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Не вдалося завантажити список дозволів Stalwart, тому змінити дозволи тут не можна. ({reason})", "The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Не вдалося завантажити список дозволів поштового сервера, тому тут не можна змінити дозволи. ({reason})",
"Named sets of permissions, given to accounts, groups and tenants.": "Іменовані набори дозволів для облікових записів, груп та орендарів.", "Named sets of permissions, given to accounts, groups and tenants.": "Іменовані набори дозволів для облікових записів, груп та орендарів.",
"Search roles": "Пошук ролей", "Search roles": "Пошук ролей",
"No roles match": "Немає відповідних ролей", "No roles match": "Немає відповідних ролей",
@@ -258,11 +258,11 @@ export const catalog: Catalog = {
"{used} used": "Зайнято {used}", "{used} used": "Зайнято {used}",
"Your role lets you view tenants but not change them.": "Ваша роль дозволяє переглядати орендарів, але не змінювати їх.", "Your role lets you view tenants but not change them.": "Ваша роль дозволяє переглядати орендарів, але не змінювати їх.",
"Logo": "Логотип", "Logo": "Логотип",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Адреса https або data-URL зображення. Stalwart показує його людям орендаря там, де показує логотип.", "An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Адреса https або data-URL зображення. Поштовий сервер показує його людям орендаря там, де показує логотип.",
"What it holds": "Вміст", "What it holds": "Вміст",
"{n} of {limit}": "{n} з {limit}", "{n} of {limit}": "{n} з {limit}",
"Limits": "Ліміти", "Limits": "Ліміти",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart не дає створити більше, ніж дозволяє ліміт. Порожнє поле — без ліміту.", "The mail server refuses to create more than a limit allows. An empty field is no limit.": "Поштовий сервер не дозволяє створити більше, ніж дозволяє ліміт. Порожнє поле означає відсутність ліміту.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, що може бути дозволено будь-кому в цьому орендарі: їхні власні ролі обмежуються тим, що надають ці. Пропонуються лише ролі, дозволи яких маєте ви самі.", "The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, що може бути дозволено будь-кому в цьому орендарі: їхні власні ролі обмежуються тим, що надають ці. Пропонуються лише ролі, дозволи яких маєте ви самі.",
"Checking what is still in this tenant…": "Перевірка того, що ще є в цього орендаря…", "Checking what is still in this tenant…": "Перевірка того, що ще є в цього орендаря…",
"It still holds accounts, domains or other things. Move them out first.": "У нього ще є облікові записи, домени чи щось інше. Спершу перенесіть їх.", "It still holds accounts, domains or other things. Move them out first.": "У нього ще є облікові записи, домени чи щось інше. Спершу перенесіть їх.",
@@ -278,7 +278,6 @@ export const catalog: Catalog = {
"Still holds {things}. Move them out first.": "Ще містить: {things}. Спершу перенесіть їх.", "Still holds {things}. Move them out first.": "Ще містить: {things}. Спершу перенесіть їх.",
"Delete tenant": "Видалити орендаря", "Delete tenant": "Видалити орендаря",
"Separate organizations on one server, each with its own people, domains and limits.": "Окремі організації на одному сервері, кожна зі своїми людьми, доменами й лімітами.", "Separate organizations on one server, each with its own people, domains and limits.": "Окремі організації на одному сервері, кожна зі своїми людьми, доменами й лімітами.",
"Tenants are a Stalwart Enterprise feature.": "Орендарі — функція Stalwart Enterprise.",
"Search tenants": "Пошук орендарів", "Search tenants": "Пошук орендарів",
"No tenants match": "Немає відповідних орендарів", "No tenants match": "Немає відповідних орендарів",
"No tenants yet": "Орендарів поки немає", "No tenants yet": "Орендарів поки немає",
@@ -1139,14 +1138,14 @@ export const catalog: Catalog = {
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Поштовий сервер доставляє їх прямо в браузер, тому вони надходять без відкритої вкладки ihasmail і містять відправника й тему. Браузер при цьому має бути запущений: якщо закрити його повністю, сповіщення почекають і надійдуть під час наступного запуску.", "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Поштовий сервер доставляє їх прямо в браузер, тому вони надходять без відкритої вкладки ihasmail і містять відправника й тему. Браузер при цьому має бути запущений: якщо закрити його повністю, сповіщення почекають і надійдуть під час наступного запуску.",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Поштовий сервер може розбудити цей браузер, але не повідомить відправника й тему. Браузер при цьому має бути запущений.", "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Поштовий сервер може розбудити цей браузер, але не повідомить відправника й тему. Браузер при цьому має бути запущений.",
"This is what a new-mail notification looks like.": "Так виглядає сповіщення про новий лист.", "This is what a new-mail notification looks like.": "Так виглядає сповіщення про новий лист.",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Ви увійшли як {user}. Пароль ніколи не зберігається в браузері: сервер тримає його зашифрованим на час сеансу, щоб спілкуватися зі Stalwart.", "You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to the mail server.": "Ви ввійшли як {user}. Пароль ніколи не зберігається в браузері; сервер зберігає його зашифрованим для кожного сеансу, щоб звертатися до поштового сервера.",
"App passwords are managed by your mail administrator.": "Паролями програм керує ваш поштовий адміністратор.", "App passwords are managed by your mail administrator.": "Паролями програм керує ваш поштовий адміністратор.",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Зміна пароля завершує інші сеанси вебпошти. Паролі програм продовжують працювати.", "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Зміна пароля завершує інші сеанси вебпошти. Паролі програм продовжують працювати.",
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Для цього облікового запису увімкнено двофакторну автентифікацію. ihasmail поки не вміє входити за кодом, тому для входу на іншому пристрої потрібен пароль програми — або двофакторну автентифікацію можна вимкнути тут.", "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Для цього облікового запису увімкнено двофакторну автентифікацію. ihasmail поки не вміє входити за кодом, тому для входу на іншому пристрої потрібен пароль програми — або двофакторну автентифікацію можна вимкнути тут.",
"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.": "Окремий пароль для поштової програми чи пристрою, який можна відкликати окремо. Паролі програм обходять двофакторні коди й тому працюють там, де запитати код неможливо.", "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.": "Окремий пароль для поштової програми чи пристрою, який можна відкликати окремо. Паролі програм обходять двофакторні коди й тому працюють там, де запитати код неможливо.",
"Copy it into {name} now — it isn't shown again.": "Скопіюйте його до {name} зараз — більше він не показується.", "Copy it into {name} now — it isn't shown again.": "Скопіюйте його до {name} зараз — більше він не показується.",
"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.": "У каталозі не знайдено інших користувачів, тому додати нікого. Уже відкритий доступ перелічено нижче, і його й далі можна закрити.", "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.": "У каталозі не знайдено інших користувачів, тому додати нікого. Уже відкритий доступ перелічено нижче, і його й далі можна закрити.",
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart не повідомляє поштовим клієнтам номер версії, тому ihasmail показує редакцію, якщо сервер її називає. ihasmail потребує версію 0.16 або новішу, і вхід зі старішою не виконується.", "This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.": "Ця вебпошта працює з поштовим сервером INBUXA, а вхід відхиляє сервер, який не надає потрібного.",
"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.": "Він {damage}, тому правила в ньому не можна показати чи змінити: збереження отриманої частини затерло б решту. Перезавантажте сторінку й спробуйте знову. Ваші правила залишаються на сервері, тут їх ніщо не змінювало.", "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.": "Він {damage}, тому правила в ньому не можна показати чи змінити: збереження отриманої частини затерло б решту. Перезавантажте сторінку й спробуйте знову. Ваші правила залишаються на сервері, тут їх ніщо не змінювало.",
"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).": "Візуальний редактор правил працює лише зі скриптами, які створив сам. Скрипт можна змінити на вкладці {tab} або почати заново з правил (наявний скрипт збережеться, але буде вимкнено).", "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).": "Візуальний редактор правил працює лише зі скриптами, які створив сам. Скрипт можна змінити на вкладці {tab} або почати заново з правил (наявний скрипт збережеться, але буде вимкнено).",
"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.": "Ваш скрипт фільтрації {damage}, тому отримано лише його частину. Додавання правила затерло б цією частиною весь скрипт. Перезавантажте сторінку й спробуйте знову.", "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.": "Ваш скрипт фільтрації {damage}, тому отримано лише його частину. Додавання правила затерло б цією частиною весь скрипт. Перезавантажте сторінку й спробуйте знову.",
@@ -1155,8 +1154,7 @@ export const catalog: Catalog = {
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Тут показано лише мови, якими перекладено ihasmail, тому список зростає разом із перекладами, а не випереджає їх: мова без текстів змусила б сторінку стверджувати, що вона написана мовою, якою не є.", "Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Тут показано лише мови, якими перекладено ihasmail, тому список зростає разом із перекладами, а не випереджає їх: мова без текстів змусила б сторінку стверджувати, що вона написана мовою, якою не є.",
"tell us about it": "повідомте нам", "tell us about it": "повідомте нам",
"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}.": "Цей переклад зроблено ШІ й не перевірено носієм мови, тому його позначено як Beta, доки хтось його не підтвердить. Про все, що звучить неправильно, варто повідомити — {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}.": "Цей переклад зроблено ШІ й не перевірено носієм мови, тому його позначено як Beta, доки хтось його не підтвердить. Про все, що звучить неправильно, варто повідомити — {report}.",
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "Власна версія ihasmail — це дата коміту, з якого його зібрано, і вказівка, звідки цей коміт узявся: {example} зібрано з коміту від 30 серпня 2026 року, що надійшов через pull request 129. Коміт, який надійшов інакше, несе замість цього короткий SHA — {sha}. Версія навмисно нічого не повідомляє про Stalwart; те, що цій збірці потрібно від сервера, вказано рядком вище.", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "Власна версія ihasmail — це дата коміту, з якого його зібрано, і вказівка, звідки цей коміт узявся: {example} зібрано з коміту від 30 серпня 2026 року, що надійшов через pull request 129. Коміт, який надійшов інакше, несе замість цього короткий SHA — {sha}. Версія навмисно нічого не повідомляє про поштовий сервер; те, що цій збірці потрібно від сервера, вказано рядком вище.",
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Новий лист", "New message": "Новий лист",
"Start a new message with what was shared?": "Почати новий лист з отриманим вмістом?", "Start a new message with what was shared?": "Почати новий лист з отриманим вмістом?",
@@ -1211,6 +1209,7 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Не вдалося зберегти фільтри: {error}", "Could not save filters: {error}": "Не вдалося зберегти фільтри: {error}",
"Could not send the receipt: {error}": "Не вдалося надіслати сповіщення про прочитання: {error}", "Could not send the receipt: {error}": "Не вдалося надіслати сповіщення про прочитання: {error}",
"Could not sign in.": "Не вдалося увійти.", "Could not sign in.": "Не вдалося увійти.",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Ви ввійшли як {user}. Ця вебпошта ніколи не бачить ваш пароль: вона зберігає токен входу від вашого поштового сервера, зашифрований для кожного сеансу.",
"About INBUXA webmail": "Про вебпошту INBUXA", "About INBUXA webmail": "Про вебпошту INBUXA",
"Mail server": "Поштовий сервер", "Mail server": "Поштовий сервер",
"You'll enter your password on your mail server's sign-in page.": "Пароль вводиться на сторінці входу вашого поштового сервера.", "You'll enter your password on your mail server's sign-in page.": "Пароль вводиться на сторінці входу вашого поштового сервера.",
+11 -12
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 Stalwart's own administration.": "详细指标、投递队列、日志和服务器设置位于 Stalwart 自身的管理界面中。", "Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "详细指标、投递队列、日志和服务器设置都在 INBUXA Admin 中。",
"Open Stalwart admin": "打开 Stalwart 管理界面", "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": "新建群组",
@@ -207,10 +207,10 @@ export const catalog: Catalog = {
"New role": "新建角色", "New role": "新建角色",
"This role carries permissions yours doesn't, so you can view it but not change it.": "此角色拥有您的角色所没有的权限,因此您可以查看但不能更改。", "This role carries permissions yours doesn't, so you can view it but not change it.": "此角色拥有您的角色所没有的权限,因此您可以查看但不能更改。",
"Your role lets you view roles but not change them.": "您的角色可以查看角色,但不能更改。", "Your role lets you view roles but not change them.": "您的角色可以查看角色,但不能更改。",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart 默认将此角色授予 {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": "权限",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart 默认授予此角色,因此无法删除。请先在 Stalwart 自身的管理界面中更改默认设置。", "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": "基于此角色",
@@ -237,7 +237,7 @@ export const catalog: Catalog = {
"Delete role": "删除角色", "Delete role": "删除角色",
"It can't be undone.": "此操作无法撤销。", "It can't be undone.": "此操作无法撤销。",
"Type {name} to confirm": "输入 {name} 以确认", "Type {name} to confirm": "输入 {name} 以确认",
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "无法加载 Stalwart 的权限列表,因此无法在此更改权限。({reason})", "The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "无法加载邮件服务器的权限列表,因此无法在此更改权限。({reason})",
"Named sets of permissions, given to accounts, groups and tenants.": "命名的权限集合,授予账户、群组和租户。", "Named sets of permissions, given to accounts, groups and tenants.": "命名的权限集合,授予账户、群组和租户。",
"Search roles": "搜索角色", "Search roles": "搜索角色",
"No roles match": "没有匹配的角色", "No roles match": "没有匹配的角色",
@@ -260,11 +260,11 @@ export const catalog: Catalog = {
"{used} used": "已用 {used}", "{used} used": "已用 {used}",
"Your role lets you view tenants but not change them.": "您的角色可以查看租户,但不能更改。", "Your role lets you view tenants but not change them.": "您的角色可以查看租户,但不能更改。",
"Logo": "徽标", "Logo": "徽标",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "https 地址或图片的 data URL。Stalwart 会在显示徽标的地方向租户成员示它。", "An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "图片的 https 地址或 data URL。邮件服务器会在显示徽标的位置向该租户成员示它。",
"What it holds": "包含内容", "What it holds": "包含内容",
"{n} of {limit}": "{n}/{limit}", "{n} of {limit}": "{n}/{limit}",
"Limits": "限额", "Limits": "限额",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart 会拒绝超出限额的创建。留空表示不限。", "The mail server refuses to create more than a limit allows. An empty field is no limit.": "邮件服务器会拒绝创建超出限额的内容。留空表示不限。",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "此租户中任何人可被允许的上限:各自的角色会被缩减到这些角色授予的范围。只提供您自己拥有其权限的角色。", "The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "此租户中任何人可被允许的上限:各自的角色会被缩减到这些角色授予的范围。只提供您自己拥有其权限的角色。",
"Checking what is still in this tenant…": "正在检查此租户中还有什么…", "Checking what is still in this tenant…": "正在检查此租户中还有什么…",
"It still holds accounts, domains or other things. Move them out first.": "它仍包含账户、域名或其他内容。请先将其移出。", "It still holds accounts, domains or other things. Move them out first.": "它仍包含账户、域名或其他内容。请先将其移出。",
@@ -280,7 +280,6 @@ export const catalog: Catalog = {
"Still holds {things}. Move them out first.": "仍包含 {things}。请先将其移出。", "Still holds {things}. Move them out first.": "仍包含 {things}。请先将其移出。",
"Delete tenant": "删除租户", "Delete tenant": "删除租户",
"Separate organizations on one server, each with its own people, domains and limits.": "同一服务器上相互独立的组织,各有自己的成员、域名和限额。", "Separate organizations on one server, each with its own people, domains and limits.": "同一服务器上相互独立的组织,各有自己的成员、域名和限额。",
"Tenants are a Stalwart Enterprise feature.": "租户是 Stalwart Enterprise 的功能。",
"Search tenants": "搜索租户", "Search tenants": "搜索租户",
"No tenants match": "没有匹配的租户", "No tenants match": "没有匹配的租户",
"No tenants yet": "还没有租户", "No tenants yet": "还没有租户",
@@ -1095,16 +1094,15 @@ export const catalog: Catalog = {
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "您的邮件服务器会把通知直接送到浏览器,因此不必打开 ihasmail 标签页也能收到,并会显示发件人和主题。但浏览器仍需保持运行——如果完全退出浏览器,通知会等到您再次打开时送达。", "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "您的邮件服务器会把通知直接送到浏览器,因此不必打开 ihasmail 标签页也能收到,并会显示发件人和主题。但浏览器仍需保持运行——如果完全退出浏览器,通知会等到您再次打开时送达。",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "您的邮件服务器可以唤醒此浏览器,但不会包含发件人或主题。浏览器仍需保持运行。", "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "您的邮件服务器可以唤醒此浏览器,但不会包含发件人或主题。浏览器仍需保持运行。",
"This is what a new-mail notification looks like.": "新邮件通知就是这个样子。", "This is what a new-mail notification looks like.": "新邮件通知就是这个样子。",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "您当前以 {user} 登录。您的密码从不保存在浏览器中;服务器会按会话加密保存,用于与 Stalwart 通信。", "You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to the mail server.": "您以 {user} 身份登录。您的密码从不保存在浏览器中;服务器会按会话加密保存,用于与邮件服务器通信。",
"App passwords are managed by your mail administrator.": "应用专用密码由您的邮件管理员管理。", "App passwords are managed by your mail administrator.": "应用专用密码由您的邮件管理员管理。",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "更改密码会让您的其他网页邮箱会话退出登录。已有的应用专用密码仍可继续使用。", "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "更改密码会让您的其他网页邮箱会话退出登录。已有的应用专用密码仍可继续使用。",
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "此账户已开启两步验证。ihasmail 目前还不能通过验证码登录,因此在其他设备上登录需要使用应用专用密码——您也可以在这里关闭两步验证。", "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "此账户已开启两步验证。ihasmail 目前还不能通过验证码登录,因此在其他设备上登录需要使用应用专用密码——您也可以在这里关闭两步验证。",
"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.": "为某个邮件应用或设备单独设置的密码,可以单独吊销。应用专用密码会跳过两步验证码,因此在无法输入验证码的应用中仍然可用。", "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.": "为某个邮件应用或设备单独设置的密码,可以单独吊销。应用专用密码会跳过两步验证码,因此在无法输入验证码的应用中仍然可用。",
"Copy it into {name} now — it isn't shown again.": "请立即把它复制到 {name}——它不会再次显示。", "Copy it into {name} now — it isn't shown again.": "请立即把它复制到 {name}——它不会再次显示。",
"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.": "目录中没有找到其他用户,因此无法添加新的共享对象。已有的共享列在下方,仍可移除。", "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.": "目录中没有找到其他用户,因此无法添加新的共享对象。已有的共享列在下方,仍可移除。",
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart 不会向邮件客户端公布版本号,因此只有在服务器给出版本类型时,ihasmail 才会报告它。ihasmail 需要 0.16 或更高版本,更旧的版本一律无法登录。", "This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.": "此网页邮箱配合 INBUXA 邮件服务器使用,登录时会拒绝不提供所需功能的服务器。",
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "ihasmail 自身的版本号是其构建所用提交的日期,后面跟着该提交的来源:{example} 表示由 2026 年 8 月 30 日的一个提交构建而成,而该提交来自第 129 号拉取请求。未经拉取请求的提交则改用简短 SHA 表示——{sha}。版本号刻意不包含任何关于 Stalwart 的信息;此版本对服务器的要求见上一行。", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "ihasmail 自身的版本号是其构建所用提交的日期,后面跟着该提交的来源:{example} 表示由 2026 年 8 月 30 日的一个提交构建而成,而该提交来自第 129 号拉取请求。未经拉取请求的提交则改用简短 SHA 表示——{sha}。版本号刻意不包含任何关于邮件服务器的信息;此版本对服务器的要求见上一行。",
// ── Constant labels ──────────────────────────────────────────────── // ── Constant labels ────────────────────────────────────────────────
"Add": "添加", "Add": "添加",
"Create subfolders": "创建子文件夹", "Create subfolders": "创建子文件夹",
@@ -1222,6 +1220,7 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "无法保存过滤器:{error}", "Could not save filters: {error}": "无法保存过滤器:{error}",
"Could not send the receipt: {error}": "无法发送已读回执:{error}", "Could not send the receipt: {error}": "无法发送已读回执:{error}",
"Could not sign in.": "无法登录。", "Could not sign in.": "无法登录。",
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "您已以 {user} 身份登录。此网页邮箱从不接触您的密码:它保存的是来自邮件服务器的登录令牌,并按会话加密。",
"About INBUXA webmail": "关于 INBUXA 网页邮箱", "About INBUXA webmail": "关于 INBUXA 网页邮箱",
"Mail server": "邮件服务器", "Mail server": "邮件服务器",
"You'll enter your password on your mail server's sign-in page.": "您将在邮件服务器的登录页面上输入密码。", "You'll enter your password on your mail server's sign-in page.": "您将在邮件服务器的登录页面上输入密码。",
+7 -7
View File
@@ -3,8 +3,7 @@ import { Eye, EyeOff, LogIn } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client"; import { ApiError } from "@/jmap/client";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { APP_VERSION, SOURCE_ARCHIVE, SOURCE_ID } from "@/lib/version";
import { APP_VERSION } from "@/lib/version";
import { DEFAULT_APP_NAME } from "@/lib/brand"; import { DEFAULT_APP_NAME } from "@/lib/brand";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -12,9 +11,9 @@ import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
export function LoginPage() { export function LoginPage() {
const login = useSession((s) => s.login); const login = useSession((s) => s.login);
// The AGPL's offer has to reach everyone who interacts with the app over the // The AGPL's offer has to reach everyone who interacts with the app over the
// network, and that includes whoever is looking at this form. The server says // network, and that includes whoever is looking at this form. ihasmail-inbuxa
// where its own source lives, so a modified deployment points at its own. // offers the exact source of this build, which the build writes next to the
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL); // app (see SOURCE_ARCHIVE), rather than a repository link that can drift.
/* /*
* What this instance calls itself. * What this instance calls itself.
* *
@@ -42,7 +41,6 @@ export function LoginPage() {
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((c) => { .then((c) => {
if (!live || !c) return; if (!live || !c) return;
if (c.sourceUrl) setSourceUrl(c.sourceUrl as string);
if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim()); if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim());
setSignIn(c.signIn === "oauth" ? "oauth" : "password"); setSignIn(c.signIn === "oauth" ? "oauth" : "password");
setDirect(c.signIn === "oauth" && c.signInDirect === true); setDirect(c.signIn === "oauth" && c.signInDirect === true);
@@ -157,7 +155,9 @@ export function LoginPage() {
<br /> <br />
<a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{t("ihasmail.org")}</a> <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{t("ihasmail.org")}</a>
{" · "} {" · "}
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">{t("AGPL-3.0 source")}</a> <a href={withBase(SOURCE_ARCHIVE)} download>{t("AGPL-3.0 source")}</a>
{" "}
<span className="notranslate" translate="no">({SOURCE_ID})</span>
</p> </p>
</form> </form>
</div> </div>
+2 -2
View File
@@ -115,12 +115,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 Stalwart's own administration.")} {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 Stalwart admin")} <ExternalLink size={13} aria-hidden="true" /> {t("Open INBUXA Admin")} <ExternalLink size={13} aria-hidden="true" />
</a> </a>
</> </>
)} )}
+2 -2
View File
@@ -153,7 +153,7 @@ export function RoleSheet({ role, roles, defaults, entries, permissionsError, on
{!creating && !locked && !can(perms, "Role", "Update") && <p className="admin-notice">{t("Your role lets you view roles but not change them.")}</p>} {!creating && !locked && !can(perms, "Role", "Update") && <p className="admin-notice">{t("Your role lets you view roles but not change them.")}</p>}
{kinds.length > 0 && ( {kinds.length > 0 && (
<p className="admin-notice warn"> <p className="admin-notice warn">
<span>{t("Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.", { kinds: kinds.join(", ") })}</span> <span>{t("The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.", { kinds: kinds.join(", ") })}</span>
</p> </p>
)} )}
@@ -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("Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration 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}
/> />
)} )}
+1 -1
View File
@@ -50,7 +50,7 @@ export function RolesAdmin({ selectedId }: { selectedId?: string }) {
let canceled = false; let canceled = false;
Promise.all([loadPermissionList(), loadPermissionCatalog()]).then( Promise.all([loadPermissionList(), loadPermissionCatalog()]).then(
([list, catalog]) => !canceled && setEntries(describePermissions(list, catalog, t("General"))), ([list, catalog]) => !canceled && setEntries(describePermissions(list, catalog, t("General"))),
(err) => !canceled && setPermissionsError(t("Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })), (err) => !canceled && setPermissionsError(t("The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })),
); );
return () => { return () => {
canceled = true; canceled = true;
+2 -2
View File
@@ -176,7 +176,7 @@ export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDe
<div className="field"> <div className="field">
<label htmlFor="admin-tenant-logo">{t("Logo")}</label> <label htmlFor="admin-tenant-logo">{t("Logo")}</label>
<input id="admin-tenant-logo" className="input" value={logo} disabled={!editable} placeholder="https://…" spellCheck={false} onChange={(e) => setLogo(e.target.value)} /> <input id="admin-tenant-logo" className="input" value={logo} disabled={!editable} placeholder="https://…" spellCheck={false} onChange={(e) => setLogo(e.target.value)} />
<span className="hint">{t("An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.")}</span> <span className="hint">{t("An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.")}</span>
</div> </div>
{!creating && ( {!creating && (
@@ -215,7 +215,7 @@ export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDe
</div> </div>
))} ))}
</div> </div>
<p className="hint">{t("Stalwart refuses to create more than a limit allows. An empty field is no limit.")}</p> <p className="hint">{t("The mail server refuses to create more than a limit allows. An empty field is no limit.")}</p>
<h3>{t("Role")}</h3> <h3>{t("Role")}</h3>
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}> <select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}>
+5 -15
View File
@@ -7,7 +7,6 @@ import { drawableLogo, getTenants, queryTenants, type DirectoryTenant } from "@/
import { formatSize } from "@/lib/format"; import { formatSize } from "@/lib/format";
import { proxiedImageUrl } from "@/lib/text/html"; import { proxiedImageUrl } from "@/lib/text/html";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
import { useSession } from "@/store/session";
import { Empty, Spinner } from "@/ui/misc"; import { Empty, Spinner } from "@/ui/misc";
import { usePermissions } from "./usePermissions"; import { usePermissions } from "./usePermissions";
import { TenantSheet } from "./TenantSheet"; import { TenantSheet } from "./TenantSheet";
@@ -18,23 +17,15 @@ const PAGE_SIZE = 50;
* Tenants: separate organizations on one server, each with its own people, * Tenants: separate organizations on one server, each with its own people,
* domains and limits. * domains and limits.
* *
* The section is offered to whoever may read tenants. INBUXA ships tenants to * The section is offered to whoever may read tenants. The INBUXA mail server
* everybody, whatever edition the server reports, so there is no edition * has one edition with tenants in it, so there is no edition check and no
* check here (public ihasmail shows only a notice unless the server reports * notice here, unlike public ihasmail.
* Enterprise). SHOW_ENTERPRISE_NOTICES still adds the notice, for talking to
* upstream Stalwart.
*/ */
export function TenantsAdmin({ selectedId }: { selectedId?: string }) { export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
const notices = useSession((s) => s.session?.ihasmail?.server?.enterpriseNotices === true); return <EnterpriseTenants selectedId={selectedId} />;
return <EnterpriseTenants selectedId={selectedId} notice={notices} />;
} }
/** Said on every Tenants page, Enterprise or not. */ function EnterpriseTenants({ selectedId }: { selectedId?: string }) {
function EnterpriseNotice({ warn }: { warn: boolean }) {
return <p className={`admin-notice${warn ? " warn" : ""}`}>{t("Tenants are a Stalwart Enterprise feature.")}</p>;
}
function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice: boolean }) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const perms = usePermissions(); const perms = usePermissions();
const [text, setText] = useState(""); const [text, setText] = useState("");
@@ -111,7 +102,6 @@ function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice
)} )}
</div> </div>
{notice && <EnterpriseNotice warn={false} />}
<div className="admin-toolbar"> <div className="admin-toolbar">
<label className="admin-search"> <label className="admin-search">
@@ -112,7 +112,7 @@ describe("the Administration dashboard", () => {
}); });
}); });
describe("the pointer to Stalwart's own administration", () => { 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 Stalwart's own administration", () => {
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("Stalwart's own administration"); 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 Stalwart's own administration", () => {
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("Stalwart's own administration"); 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();
}); });
}); });
@@ -86,7 +86,7 @@ describe("the role sheet", () => {
it("warns about a default role and will not delete it", async () => { it("warns about a default role and will not delete it", async () => {
signIn(VIEWER); signIn(VIEWER);
await render(roles.get("user")!, { user: ["user"], group: [], tenant: [], admin: [] }); await render(roles.get("user")!, { user: ["user"], group: [], tenant: [], admin: [] });
expect(host.textContent).toContain("Stalwart gives this role by default to users"); expect(host.textContent).toContain("The mail server gives this role by default to users");
expect(button(host, "Delete role…")?.disabled).toBe(true); expect(button(host, "Delete role…")?.disabled).toBe(true);
}); });
}); });
@@ -69,15 +69,14 @@ describe("the Tenants page where the installation asks for Enterprise notices",
host.remove(); host.remove();
}); });
it("says tenants are Enterprise above the list, as the demo does", async () => { it("shows no Enterprise notice, even where the installation asks for one", async () => {
signIn("enterprise", true); signIn("enterprise", true);
const { hook } = memoryLocation({ path: "/admin/tenants" }); const { hook } = memoryLocation({ path: "/admin/tenants" });
await act(async () => { await act(async () => {
root.render(<Router hook={hook}><TenantsAdmin /></Router>); root.render(<Router hook={hook}><TenantsAdmin /></Router>);
}); });
await act(async () => {}); await act(async () => {});
expect(host.querySelector(".admin-notice")?.textContent).toBe("Tenants are a Stalwart Enterprise feature."); expect(host.querySelector(".admin-notice")).toBeNull();
expect(host.querySelector(".admin-notice.warn")).toBeNull();
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp"); expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
}); });
}); });
+5 -7
View File
@@ -1,7 +1,6 @@
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { APP_VERSION, SOURCE_ARCHIVE, SOURCE_ID } from "@/lib/version";
import { APP_VERSION } from "@/lib/version";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { t, tNode } from "@/lib/i18n"; import { t, tNode } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -9,8 +8,7 @@ import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
export function AboutSettings() { export function AboutSettings() {
const session = useSession((s) => s.session); const session = useSession((s) => s.session);
const caps = Object.keys(session?.capabilities ?? {}); const caps = Object.keys(session?.capabilities ?? {});
// A deployment running modified code should offer its own source, not ours. // The exact source of this build, written next to the app by the build.
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
return ( return (
<div> <div>
{/* ihasmail-inbuxa: INBUXA's webmail, built on ihasmail, whose version {/* ihasmail-inbuxa: INBUXA's webmail, built on ihasmail, whose version
@@ -23,7 +21,7 @@ export function AboutSettings() {
<InbuxaWordmark height={26} /> <InbuxaWordmark height={26} />
{/* A product name and a version string: neither is a word to translate. */} {/* A product name and a version string: neither is a word to translate. */}
<div style={{ fontWeight: 700 }} className="notranslate" translate="no">ihasmail v{APP_VERSION}</div> <div style={{ fontWeight: 700 }} className="notranslate" translate="no">ihasmail v{APP_VERSION}</div>
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a> })}</div> <div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={withBase(SOURCE_ARCHIVE)} download className="notranslate" translate="no">source.tar.gz ({SOURCE_ID})</a> })}</div>
</div> </div>
</div> </div>
<h2>{t("Server")}</h2> <h2>{t("Server")}</h2>
@@ -36,8 +34,8 @@ export function AboutSettings() {
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr> <tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>
</tbody> </tbody>
</table> </table>
<p className="hint" style={{ marginTop: 6 }}>{t("Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.")}</p> <p className="hint" style={{ marginTop: 6 }}>{t("This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.")}</p>
<p className="hint">{tNode("ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> })}</p> <p className="hint">{tNode("ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> })}</p>
<h2>{t("Server capabilities")}</h2> <h2>{t("Server capabilities")}</h2>
<div className="row wrap gap-4"> <div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)} {caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
+5 -1
View File
@@ -60,7 +60,11 @@ export function SecuritySettings() {
return ( return (
<div> <div>
<h1>{t("Security & sessions")}</h1> <h1>{t("Security & sessions")}</h1>
<p className="lead">{tNode("You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.", { user: <b className="notranslate" translate="no">{session?.username}</b> })}</p> <p className="lead">
{session?.ihasmail?.signIn === "oauth"
? tNode("You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.", { user: <b className="notranslate" translate="no">{session?.username}</b> })
: tNode("You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to the mail server.", { user: <b className="notranslate" translate="no">{session?.username}</b> })}
</p>
<h2>{t("Password")}</h2> <h2>{t("Password")}</h2>
{unsupported ? ( {unsupported ? (
+22 -2
View File
@@ -3,11 +3,31 @@ import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { resolveVersion } from "../scripts/version.mjs"; import { resolveVersion } from "../scripts/version.mjs";
import { baseUrlOf } from "../scripts/basePath.mjs"; import { baseUrlOf } from "../scripts/basePath.mjs";
import { sourceIdentity, writeSourceArchive } from "../scripts/source-archive.mjs";
// Resolved here, at build time: the browser has no git to ask, and neither does // Resolved here, at build time: the browser has no git to ask, and neither does
// the Docker build, which is handed the answer as IHASMAIL_VERSION instead. // the Docker build, which is handed the answer as IHASMAIL_VERSION instead.
const version = resolveVersion(); const version = resolveVersion();
/*
* ihasmail-inbuxa: the AGPL's offer for this build. The whole project's source
* (web and server), exactly as built, goes into dist/source.tar.gz, and its
* identity into the app so the download link can name it. See
* scripts/source-archive.mjs.
*/
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
const source = sourceIdentity(projectRoot);
function sourceArchive(): Plugin {
return {
name: "inbuxa-source-archive",
apply: "build",
closeBundle() {
writeSourceArchive(projectRoot, fileURLToPath(new URL("./dist/source.tar.gz", import.meta.url)), "ihasmail-inbuxa", source);
},
};
}
/* /*
* Where the app is mounted. Unlike everything else ihasmail is told, this one * Where the app is mounted. Unlike everything else ihasmail is told, this one
* cannot wait until the process starts: the hashed asset URLs are written into * cannot wait until the process starts: the hashed asset URLs are written into
@@ -59,8 +79,8 @@ function assetList(): Plugin {
export default defineConfig({ export default defineConfig({
base, base,
plugins: [react(), assetList()], plugins: [react(), assetList(), sourceArchive()],
define: { __IHASMAIL_VERSION__: JSON.stringify(version) }, define: { __IHASMAIL_VERSION__: JSON.stringify(version), __SOURCE_ID__: JSON.stringify(source.id) },
resolve: { resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
}, },