Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc0caeae9 | ||
|
|
9647ead8d4 | ||
|
|
c587268c97 | ||
|
|
ce683f94bd | ||
|
|
3dd8c7c2dd | ||
|
|
de71572b9d | ||
|
|
029afc21c4 | ||
|
|
64dbb30e70 | ||
|
|
28acad6865 | ||
|
|
5f808f3033 | ||
|
|
15b1838e21 | ||
|
|
822314e8b7 | ||
|
|
5027bd1e73 | ||
|
|
f44987e391 | ||
|
|
b6cc762d23 | ||
|
|
f1638b2fee | ||
|
|
7c0e278ee8 | ||
|
|
93c9660421 | ||
|
|
430fc2673c | ||
|
|
b79db9098a | ||
|
|
16e0761ddf | ||
|
|
5f5672fed3 | ||
|
|
1dafb4bc79 | ||
|
|
d279fe8f90 | ||
|
|
82e217155b | ||
|
|
b5c073955d | ||
|
|
b0564679e6 | ||
|
|
724ff0b077 | ||
|
|
a666cdbcdc | ||
|
|
5855da0ba9 | ||
|
|
c3d2dc2418 | ||
|
|
54b316ae36 |
@@ -61,6 +61,12 @@ MAX_UPLOAD_BYTES=52428800
|
||||
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
|
||||
IMAGE_PROXY=1
|
||||
|
||||
# In-app administration, for accounts whose Stalwart role manages accounts and
|
||||
# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses
|
||||
# Stalwart's registry methods beyond an account's own password, app passwords
|
||||
# and settings. Stalwart's own admin interface is not affected.
|
||||
ADMINISTRATION=1
|
||||
|
||||
# Branding
|
||||
APP_NAME=ihasmail
|
||||
|
||||
|
||||
+125
-3
@@ -12,10 +12,12 @@ questions:
|
||||
| [KNOWN-ISSUES.md](KNOWN-ISSUES.md) | What was verified live, and where Stalwart departs from a spec |
|
||||
| [docs.ihasmail.org](https://docs.ihasmail.org) | How to install, configure and drive each of these |
|
||||
|
||||
Written against the tree at Stalwart **0.16.21**, which is the version the live
|
||||
Written against the tree at Stalwart **0.16.22**, which is the version the live
|
||||
instance runs. Behaviours carrying an older version below were checked against
|
||||
that one and have not changed since; where 0.16.21 changed something, the entry
|
||||
says so and names both. ihasmail
|
||||
that one and have not changed since; where a later release changed something,
|
||||
the entry says so and names both. 0.16.22 changed nothing described here: its
|
||||
client-visible changes are in what `CalendarEvent/get` and `ContactCard/get`
|
||||
return, and [KNOWN-ISSUES.md](KNOWN-ISSUES.md) lists them. ihasmail
|
||||
requires 0.16 or newer and refuses older servers at sign-in, by name.
|
||||
|
||||
## The shape of it
|
||||
@@ -1093,6 +1095,117 @@ needed nothing in either half.
|
||||
|
||||
---
|
||||
|
||||
# Administration
|
||||
|
||||
An account whose Stalwart role manages other accounts finds **Administration**
|
||||
in the account menu, top right. Nobody else sees the entry, and the page
|
||||
redirects them to their mail if they type its address in.
|
||||
|
||||
## What it offers is what the role allows
|
||||
|
||||
At sign-in the server already asks Stalwart's `GET /api/account` for the
|
||||
edition; it now keeps the account's **permissions** from the same answer and
|
||||
hands them to the browser with the session. The menu appears for an account
|
||||
that can query and read accounts (`sysAccountQuery`, `sysAccountGet`) or
|
||||
domains (`sysDomainQuery`, `sysDomainGet`), and each control inside is there only when the matching permission is:
|
||||
**New account** with `sysAccountCreate`, editing with `sysAccountUpdate`,
|
||||
**Delete** with `sysAccountDestroy`. A system administrator, a tenant
|
||||
administrator and a custom helpdesk role each see the same screen shaped to
|
||||
what they can do.
|
||||
|
||||
None of that is the security boundary. Every read and write is a JMAP `x:`
|
||||
call through the ordinary `/api/jmap` proxy, authenticated as the signed-in
|
||||
account, and Stalwart decides each one — scoping a tenant administrator's
|
||||
queries to their own tenant and refusing anything the role does not allow.
|
||||
The client's gating only avoids offering what would fail.
|
||||
|
||||
## Accounts
|
||||
|
||||
- **List and search** by name or address, fifty to a page, newest first — the
|
||||
server's own order. Role, storage used against the limit, and groups at a
|
||||
glance.
|
||||
- **Create** an account on any domain the role can see: display name, address,
|
||||
a generated password to copy and pass on, role, and storage limit.
|
||||
- **Edit** the display name, other addresses (aliases), role and storage limit.
|
||||
One save sends only what changed.
|
||||
- **Set a new password.** It goes into the account's existing password
|
||||
credential, and signs the person out of every app and device using the old
|
||||
one, because Stalwart ties every token to the password.
|
||||
- **Delete**, after typing the address to confirm. Stalwart removes the
|
||||
mailbox's data in the background, and says so.
|
||||
|
||||
Roles are offered only when the viewer holds every permission they carry,
|
||||
which is the check Stalwart makes on a grant. It does **not** make that check
|
||||
when only a password changes, or on a delete, so an account allowed to edit
|
||||
accounts could otherwise reset the password of one that can do more and sign
|
||||
in as it. ihasmail shows any account that outranks the viewer read-only, and
|
||||
counts a role it cannot read as outranking rather than not. Nobody can change
|
||||
their own role or delete the account they are signed in with.
|
||||
|
||||
## Domains
|
||||
|
||||
For a role that can read domains (`sysDomainQuery`, `sysDomainGet`):
|
||||
|
||||
- **List and search**, with how many accounts use each domain and whether its
|
||||
DNS records, DKIM keys and certificate are managed automatically or by hand.
|
||||
- **Add** a domain. Stalwart gives a new one automatic DKIM, so it has keys
|
||||
straight away.
|
||||
- **Edit** the description, other names for the domain, the catch-all address,
|
||||
and plus addressing (`name+anything@`). A plus-addressing rule set on the
|
||||
server is shown and left alone.
|
||||
- **DNS records**, one per row with a copy button each, and the lot as a zone
|
||||
file. Stalwart computes them per domain — MX, SPF, DKIM, DMARC, the service
|
||||
records, MTA-STS, TLS reporting, CAA — and ihasmail joins a long DKIM record
|
||||
back into the single value a DNS provider's form wants.
|
||||
- **DKIM keys** with their stage — signing, published and waiting, retiring —
|
||||
read-only, because the server creates and rotates them itself when DKIM is
|
||||
automatic, and a key added by hand needs its private key.
|
||||
- **Remove** a domain once nothing uses it. While accounts do, removal says how
|
||||
many and stays unavailable. The domain's own DKIM keys go with it, since the
|
||||
server will not remove a domain its keys still name — which also means a role
|
||||
that cannot delete keys cannot remove a domain that has any.
|
||||
|
||||
Switching DNS, DKIM or certificate management between automatic and manual,
|
||||
and choosing a DNS or ACME provider, stay in Stalwart's own interface for now.
|
||||
|
||||
## Only on your own device
|
||||
|
||||
Administration is available only to a session signed in with **"This is my own
|
||||
device"** ticked. A borrowed laptop or a shared machine is exactly where nobody
|
||||
should be able to reset a password or remove a domain, and that tickbox is the
|
||||
one question the sign-in page already asks about where it is being used.
|
||||
|
||||
It is enforced the same way as the switch below: an untrusted session is sent
|
||||
no permissions, and the JMAP proxy refuses registry methods beyond the account's
|
||||
own. The menu still shows **Administration** to an administrator in that
|
||||
session, greyed out, with the reason and what to do about it — signing in again
|
||||
with the box ticked — rather than losing the entry without a word. All the
|
||||
server tells that session is that the account administers, never what it may do.
|
||||
|
||||
## An operator can turn it off
|
||||
|
||||
`ADMINISTRATION=0` at launch removes it for everyone, and not only from the
|
||||
menu. The permissions are no longer sent to the browser, and the JMAP proxy
|
||||
refuses Stalwart registry methods except the ones about the signed-in account
|
||||
itself — its password, app passwords, API keys, public keys, masked addresses
|
||||
and account settings. Without that, hiding the menu would leave an
|
||||
administrator's browser console able to make every call the menu made.
|
||||
Stalwart's own interface is unaffected; this decides what ihasmail offers.
|
||||
|
||||
## Stateless, as everything else
|
||||
|
||||
Nothing new is stored anywhere. There is no admin route on ihasmail's server,
|
||||
no database and no cache beyond the permissions list that rides along with the
|
||||
session information already kept for thirty minutes — so a role granted or
|
||||
taken away shows in the menu at the next sign-in or within half an hour, and in
|
||||
the meantime Stalwart refuses what is no longer allowed.
|
||||
|
||||
Accounts and domains are the first two sections. Groups, mailing lists, roles
|
||||
and tenants are Stalwart capabilities the same screen is laid out to take;
|
||||
reporting, queues, logs and server settings are deliberately out of scope.
|
||||
|
||||
---
|
||||
|
||||
# Live updates and notifications
|
||||
|
||||
- **JMAP push over EventSource**, proxied by ihasmail's server so the browser
|
||||
@@ -1276,6 +1389,7 @@ costs something to get wrong is the one that assumes the machine is yours.
|
||||
| Idle sign-out | after 5 minutes | none |
|
||||
| Kept on the computer | nothing | settings cache, recent addresses, username |
|
||||
| Background notifications | refused | available |
|
||||
| Administration | unavailable | available, if the role allows it |
|
||||
|
||||
Local storage is gated on that answer for **reads** as well as writes — a
|
||||
machine trusted once still has residue, and honouring it would let a previous
|
||||
@@ -1459,6 +1573,7 @@ wizard, because either would be state.
|
||||
| `UPSTREAM_TIMEOUT` | `30000` | Milliseconds |
|
||||
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MB |
|
||||
| `IMAGE_PROXY` | `1` | Privacy proxy for remote images |
|
||||
| `ADMINISTRATION` | `1` | Offer in-app administration to accounts whose Stalwart role allows it; `0` turns it off, in the proxy as well as the menu |
|
||||
| `LOGIN_RATE_LIMIT` | `10` | Attempts per window |
|
||||
| `COOKIE_NAME` | `ihm_session` | |
|
||||
| `APP_NAME` | `ihasmail` | Branding |
|
||||
@@ -1544,6 +1659,13 @@ moves an occurrence renumbering the ids around it. Two switches:
|
||||
`MOCK_NO_REGISTRY=1` omits the Stalwart capability so the sign-in refusal can be
|
||||
tested.
|
||||
|
||||
Administration works against it too, with a directory of about thirty accounts,
|
||||
three domains with their DKIM keys and zone files, behind the same permission
|
||||
names Stalwart uses. `MOCK_ROLE` decides who the
|
||||
demo user is: `admin` (the default), `tenant-admin`, `helpdesk` — a custom role
|
||||
that may view and edit accounts but not create or delete them — or `user`, who
|
||||
is not offered the menu at all.
|
||||
|
||||
---
|
||||
|
||||
# What it does not do
|
||||
|
||||
+31
-3
@@ -4,7 +4,7 @@ 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.21**, and as of **2026-08-26 there is nothing
|
||||
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
|
||||
@@ -17,6 +17,16 @@ things a client can see, one of which resolved an entry below outright. 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.
|
||||
@@ -32,6 +42,24 @@ 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).
|
||||
|
||||
- **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 parenthesised 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.
|
||||
|
||||
- **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 recognised 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 localise 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 catalogues 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 catalogue 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 catalogue 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.
|
||||
@@ -42,7 +70,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
- **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 now omits 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.
|
||||
- **`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.
|
||||
@@ -62,7 +90,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
- **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 honours 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, cancelled 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 cancelled 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). Cancelling 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.
|
||||
- **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). Cancelling 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 behaviour 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.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-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.21" src="https://img.shields.io/badge/Stalwart-0.16.21-6366f1?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>
|
||||
@@ -33,6 +33,16 @@ durable belongs to Stalwart; the container is disposable.
|
||||
| 🧪 **[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 |
|
||||
|
||||
### Companion tools
|
||||
|
||||
Two tools for getting a Stalwart server ready for ihasmail, one for each place
|
||||
you might be starting from:
|
||||
|
||||
| | Starting from | What it does |
|
||||
| --- | --- | --- |
|
||||
| 🚀 **[ihasmail-oneshot](https://github.com/Coffey-Labs/ihasmail-oneshot)** | **Nothing** — a fresh Linux host with Docker | One command deploys a new Stalwart and a new ihasmail on a single host, already linked: certificates for both, the first mailboxes, and the DNS records to publish. Or `--local` for a loopback-only pair to try it |
|
||||
| ⬆️ **[stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator)** | **An existing Stalwart 0.15.5** server | Upgrades it in place to the 0.16 series ihasmail requires, checkpointing every phase so an interrupted run resumes, and validating the server afterwards. Take a snapshot first: it does not undo a migration |
|
||||
|
||||
> **Releases are weekly, so `latest` normally lags `main`.** Automation builds
|
||||
> and publishes the GHCR image every **Monday at 09:00 UTC**, in a week that had
|
||||
> changes. Between one Monday and the next, `main` is ahead of the newest image
|
||||
@@ -73,6 +83,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
|
||||
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
||||
- **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes)
|
||||
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
|
||||
- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: create, edit and delete accounts and set their passwords; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
|
||||
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
||||
|
||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||
@@ -87,22 +98,36 @@ wrong guess had somewhere to fall back to, so it failed *quietly* — and that
|
||||
reached production. With one supported generation a wrong guess is a loud error
|
||||
on the first call.
|
||||
|
||||
**Validated against 0.16.21**, released 6 September 2026: the app was run
|
||||
against a real instance of it and the mail, calendar and contacts paths were
|
||||
exercised by hand. Four of that release's JMAP changes are visible to a client
|
||||
**Validated against 0.16.22**, released 13 September 2026: the live instance
|
||||
runs it and the app has been tested against it. Four of its JMAP changes are
|
||||
visible to a client, all in calendars and contacts:
|
||||
`CalendarEvent/get` returns `baseEventId` only for a synthetic id, so an event
|
||||
read by its stored id now carries `null` there rather than its own id; it
|
||||
returns `null` for `recurrenceRule` and `recurrenceOverrides` asked for on a
|
||||
synthetic id; `useDefaultAlerts` is stored per user and reads `false` when never
|
||||
set; and `CalendarEvent/get` and `ContactCard/get` return only `id` for an empty
|
||||
`properties` list, rather than everything. The mock reproduces all four.
|
||||
|
||||
Before it, **0.16.21**, released 6 September 2026: the app was run against a
|
||||
real instance of it and the mail, calendar and contacts paths were exercised by
|
||||
hand. Four of that release's JMAP changes are visible to a client
|
||||
— an occurrence of a recurring event is now identified by its recurrence id
|
||||
rather than by its position in the series, so an id held across a write no
|
||||
longer silently 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.
|
||||
reproduces those four.
|
||||
|
||||
- Still on 0.15? 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).
|
||||
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
No Stalwart yet? [ihasmail-oneshot](https://github.com/Coffey-Labs/ihasmail-oneshot)
|
||||
sets up both on one host in a single command. The steps below are for pointing
|
||||
ihasmail at a Stalwart you already run.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
|
||||
@@ -374,11 +399,12 @@ 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
|
||||
**per-account** rather than session-level, identity signatures capped at 2047
|
||||
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
||||
RFC 8984's. Three switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
||||
RFC 8984's. Four switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
||||
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
||||
the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a
|
||||
calendar write that asks for scheduling messages, the way an account without
|
||||
that permission is refused.
|
||||
that permission is refused; and `MOCK_ROLE` decides who the demo user is for
|
||||
Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`.
|
||||
|
||||
It tracks the current release rather than 0.16 in general, and each behaviour
|
||||
is confirmed against a real server before it is copied here — the comments say
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.
|
||||
|
||||
- **Administration beyond accounts and domains.** The Administration menu manages accounts and domains today — see [FEATURES.md](FEATURES.md#administration). Groups, mailing lists, roles, DNS and ACME providers and tenants 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. Reporting, queues, logs and 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.
|
||||
|
||||
+17
-1
@@ -214,7 +214,23 @@ prune_old_images() {
|
||||
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
VERSION="$(node scripts/version.mjs)"
|
||||
# The version is the same sum scripts/version.mjs does -- the commit's own
|
||||
# date, plus the pull request it arrived through or its short SHA -- done here
|
||||
# in shell because a host that only runs containers has git and docker and no
|
||||
# node. Given IHASMAIL_VERSION, use it as given, as the script would.
|
||||
version_from_git() {
|
||||
local date subject sha y m d
|
||||
date="$(git show -s --format=%cs HEAD)"
|
||||
subject="$(git show -s --format=%s HEAD)"
|
||||
sha="$(git rev-parse --short HEAD)"
|
||||
IFS=- read -r y m d <<<"$date"
|
||||
if [[ "$subject" =~ ^Merge\ pull\ request\ \#([0-9]+) ]]; then
|
||||
printf '%d.%d.%d+pr%s\n' "$((10#$y))" "$((10#$m))" "$((10#$d))" "${BASH_REMATCH[1]}"
|
||||
else
|
||||
printf '%d.%d.%d+g%s\n' "$((10#$y))" "$((10#$m))" "$((10#$d))" "$sha"
|
||||
fi
|
||||
}
|
||||
VERSION="${IHASMAIL_VERSION:-$(version_from_git)}"
|
||||
# A Docker tag may not contain "+", and every version has one now:
|
||||
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
|
||||
# request. The image is tagged with the "+" turned into "-"; what the build is
|
||||
|
||||
@@ -29,17 +29,49 @@
|
||||
import ts from "typescript-ast";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
/*
|
||||
* Two sets, because there are two questions and they need different nets.
|
||||
*
|
||||
* `wanted` is what a catalogue *owes*: the strings that actually reach t(),
|
||||
* tc() or plural(). Coverage is measured against it, so it has to stay strict
|
||||
* -- widening it would count every CSS class and JMAP method name as an
|
||||
* untranslated string.
|
||||
*
|
||||
* `seen` is every string literal in the source, and answers only "is this
|
||||
* catalogue key still written down anywhere". Stale detection needs the wide
|
||||
* net: a key reaches t() as a variable often enough that a strict set reports
|
||||
* mostly false alarms.
|
||||
*/
|
||||
const wanted = new Set();
|
||||
const seen = new Set();
|
||||
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
|
||||
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const visit = (n) => {
|
||||
/*
|
||||
* Labels held in a constant and translated where they render -- t(s.label)
|
||||
* -- reach t() as a variable, so there is no literal for this to find and
|
||||
* every one of them looked "stale". They are collected from the constants
|
||||
* instead: a `label:` property, or a value in an object of them. Without
|
||||
* this the stale check cried wolf 33 times and would have been switched
|
||||
* off, which is the only outcome worse than not having it.
|
||||
* Anything held in a constant and translated where it renders -- t(s.label),
|
||||
* t(b.description), t(group) -- reaches t() as a variable, so there is no
|
||||
* literal at the call site and every one of them looked "stale".
|
||||
*
|
||||
* This used to chase the shapes one at a time: a `label:` property, then an
|
||||
* object named *_LABELS. It still cried wolf, because the shapes kept
|
||||
* coming -- `description:` and `group:` on keyboard bindings, the calendar's
|
||||
* view names, the read-receipt refusals, the palette names. 41 reported,
|
||||
* 10 of them real. A report that is three-quarters false is one nobody acts
|
||||
* on, which is how these sat unread long enough to be worth a commit of
|
||||
* their own.
|
||||
*
|
||||
* So: any string literal anywhere in the source counts as a use. That
|
||||
* under-reports -- a literal that exists but is never passed to t() will not
|
||||
* be flagged -- and that is the right way round. A missed stale key costs a
|
||||
* line of dead translation; a false one costs the credibility of the whole
|
||||
* check, and then every real finding with it.
|
||||
*/
|
||||
if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) seen.add(n.text);
|
||||
if (ts.isJsxText(n)) { const text = n.text.trim(); if (text) seen.add(text); }
|
||||
/*
|
||||
* A `label:` in a constant is still a string somebody has to translate --
|
||||
* it reaches t() one render later -- so it stays part of what a catalogue
|
||||
* owes, and out of coverage it would flatter the number.
|
||||
*/
|
||||
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
|
||||
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /_LABELS?$/.test(n.name.text)) {
|
||||
@@ -58,6 +90,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
||||
// fallback, not a second obligation -- asking for both would report
|
||||
// work that does not exist.
|
||||
wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
|
||||
seen.add(`${a0.text}\u0004${n.arguments[1].text}`);
|
||||
}
|
||||
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
|
||||
for (const p of n.arguments[1].properties) {
|
||||
@@ -105,15 +138,14 @@ for (const file of globSync("web/src/locales/*.ts")) {
|
||||
ts.forEachChild(n, visit);
|
||||
};
|
||||
visit(src);
|
||||
const stale = [...have].filter((k) => !wanted.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
|
||||
const stale = [...have].filter((k) => !seen.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
|
||||
const missing = [...wanted].filter((k) => !have.has(k));
|
||||
const pct = Math.round(((wanted.size - missing.length) / wanted.size) * 100);
|
||||
console.log(`${tag}: ${wanted.size - missing.length}/${wanted.size} translated (${pct}%), ${missing.length} falling back to English`);
|
||||
if (stale.length) {
|
||||
failed = true;
|
||||
console.log(`\n ${stale.length} STALE key(s) — translated but never looked up, so they do nothing:`);
|
||||
for (const k of stale.slice(0, 25)) console.log(` ${JSON.stringify(k)}`);
|
||||
if (stale.length > 25) console.log(` …and ${stale.length - 25} more`);
|
||||
for (const k of stale) console.log(` ${JSON.stringify(k)}`);
|
||||
}
|
||||
if (process.argv.includes("--missing")) {
|
||||
console.log(`\n missing:`);
|
||||
|
||||
@@ -33,8 +33,8 @@ test("an account with no locale set yields none, rather than a guess", () => {
|
||||
});
|
||||
|
||||
test("neither answering leaves the locale unknown", () => {
|
||||
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null, permissions: [] });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null, permissions: [] });
|
||||
});
|
||||
|
||||
test("locales that carry no language are dropped, not passed through", () => {
|
||||
@@ -48,7 +48,7 @@ test("a server without the registry is not asked for anything", async () => {
|
||||
// fails the whole request rather than the one call.
|
||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
||||
assert.deepEqual(info, { locale: null, edition: null });
|
||||
assert.deepEqual(info, { locale: null, edition: null, permissions: [] });
|
||||
});
|
||||
|
||||
test("no capabilities at all is treated the same way", async () => {
|
||||
@@ -110,3 +110,32 @@ test("a shared account carrying the capability is enough to recognise the server
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* With a domain mapped to its own Stalwart (#238), everything asked about the
|
||||
* account has to go to that server. The locale lookup resolved Stalwart's
|
||||
* `apiUrl` against the default server instead, so a mapped account's locale
|
||||
* was requested from a server that had never heard of it.
|
||||
*/
|
||||
test("account info is asked of the server that issued the session", async () => {
|
||||
const seen: string[] = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
seen.push(String(input instanceof Request ? input.url : input));
|
||||
return new Response(JSON.stringify({ methodResponses: [], edition: "oss" }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const session = {
|
||||
capabilities: baseCaps,
|
||||
accounts: { a1: { accountCapabilities: { [STALWART]: {} } } },
|
||||
primaryAccounts: { [STALWART]: "a1" },
|
||||
apiUrl: "https://mail.mapped.test/jmap/",
|
||||
baseUrl: "https://mail.mapped.test",
|
||||
};
|
||||
await getAccountInfo("session-mapped-domain", "Basic x", session as never);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
assert.ok(seen.length >= 2, "asks for both the locale and the edition");
|
||||
for (const url of seen) assert.ok(url.startsWith("https://mail.mapped.test/"), `${url} went to the wrong server`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration, mayNameRegistryMethod } from "./adminGate.js";
|
||||
|
||||
const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) });
|
||||
|
||||
/**
|
||||
* With ADMINISTRATION=0 an administrator's browser must not be a way round the
|
||||
* operator's decision. Hiding the menu would leave the proxy forwarding the
|
||||
* very calls the menu made.
|
||||
*/
|
||||
test("mail, calendars and the rest pass untouched", () => {
|
||||
const r = gateAdministration(req("Email/query", "Mailbox/get", "CalendarEvent/set", "FileNode/get", "Principal/getAvailability"));
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test("the account's own registry objects pass", () => {
|
||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
||||
});
|
||||
|
||||
test("directory and server objects are refused, and named", () => {
|
||||
for (const m of ["x:Account/get", "x:Domain/set", "x:Role/query", "x:Tenant/get", "x:SystemSettings/set", "x:DkimSignature/get"]) {
|
||||
assert.deepEqual(gateAdministration(req("Email/get", m)), { ok: false, method: m });
|
||||
}
|
||||
});
|
||||
|
||||
test("a body that could name a registry method and cannot be read is refused rather than forwarded", () => {
|
||||
assert.deepEqual(gateAdministration('{"methodCalls": [["x:Account/get"'), { ok: false, method: null });
|
||||
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: "x:Account/get" })), { ok: false, method: null });
|
||||
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]], note: "x:" })), { ok: false, method: null });
|
||||
});
|
||||
|
||||
test("a body that cannot name a registry method is forwarded exactly as it came", () => {
|
||||
// Most traffic from a session that may not administer: no parse, no rewrite.
|
||||
const raw = '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Email/get",{"ids":["a"]},"c"]]}';
|
||||
assert.equal(mayNameRegistryMethod(raw), false);
|
||||
assert.deepEqual(gateAdministration(raw), { ok: true, body: raw });
|
||||
});
|
||||
|
||||
test("a method name hidden behind a unicode escape is still found", () => {
|
||||
// JSON.parse and the server both read \u0078 as "x"; a substring check alone would not.
|
||||
const raw = '{"methodCalls":[["\\u0078:Account/get",{},"c"]]}';
|
||||
assert.equal(mayNameRegistryMethod(raw), true);
|
||||
assert.deepEqual(gateAdministration(raw), { ok: false, method: "x:Account/get" });
|
||||
});
|
||||
|
||||
/**
|
||||
* The operator's rule: administration only from a session signed in with
|
||||
* "This is my own device" ticked, and never when the installation turned it off.
|
||||
*/
|
||||
test("administration needs both the installation and a device marked as the person's own", () => {
|
||||
assert.equal(administrationAllowed(true, true), true);
|
||||
assert.equal(administrationAllowed(true, false), false);
|
||||
assert.equal(administrationAllowed(false, true), false);
|
||||
});
|
||||
|
||||
test("an account counts as an administrator by the same test the menu makes", () => {
|
||||
assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysAccountQuery", "sysDomainGet"]), false);
|
||||
assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false);
|
||||
});
|
||||
|
||||
test("what is forwarded is what was checked", () => {
|
||||
// A duplicate key is read one way by JSON.parse; forwarding the parsed form
|
||||
// means the server cannot read it the other way.
|
||||
const raw = '{"methodCalls":[["x:Account/get",{},"a"]],"methodCalls":[["Email/get",{},"b"]]}';
|
||||
const r = gateAdministration(raw);
|
||||
assert.equal(r.ok, true);
|
||||
if (r.ok) assert.equal(r.body, JSON.stringify({ methodCalls: [["Email/get", {}, "b"]] }));
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* What the JMAP proxy lets through for a session that may not administer:
|
||||
* the operator turned it off (`ADMINISTRATION=0`), or the session was signed
|
||||
* in without "This is my own device".
|
||||
*
|
||||
* Hiding the menu is not turning it off. `/api/jmap` forwards any method the
|
||||
* browser sends, and Stalwart's registry answers whatever the credential's role
|
||||
* allows -- so without this, an administrator could still manage accounts, or
|
||||
* the whole server, from the browser console of an installation whose operator
|
||||
* said no. With it off, the proxy refuses every `x:` method except the few that
|
||||
* are about the signed-in account itself.
|
||||
*
|
||||
* An allowlist rather than a list of administrative objects, because the
|
||||
* registry has dozens of them -- listeners, stores, tracers, system settings --
|
||||
* and a new release adds more. An object not named here is refused, which errs
|
||||
* towards the operator's decision.
|
||||
*
|
||||
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||
* touched: they act on what the account can already reach.
|
||||
*/
|
||||
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]);
|
||||
|
||||
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
|
||||
|
||||
/**
|
||||
* Whether a session may administer at all: the installation allows it, and
|
||||
* the person signing in said the device is their own.
|
||||
*
|
||||
* The second half is the operator's rule, not Stalwart's. A borrowed laptop or
|
||||
* a library machine is exactly where a session should not be able to reset a
|
||||
* password or remove a domain, and "This is my own device" is the one thing
|
||||
* the sign-in form already asks that says where it is being used. An untrusted
|
||||
* session is also signed out when idle and wipes its local data, so nothing
|
||||
* about it suits an administrator's work.
|
||||
*/
|
||||
export function administrationAllowed(enabled: boolean, remember: boolean): boolean {
|
||||
return enabled && remember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account's permissions would put Administration in its menu --
|
||||
* the same test the client makes, so the server can say why it is missing
|
||||
* without handing over the permissions themselves.
|
||||
*/
|
||||
export function grantsAdministration(permissions: readonly string[]): boolean {
|
||||
const has = new Set(permissions);
|
||||
return (has.has("sysAccountQuery") && has.has("sysAccountGet")) || (has.has("sysDomainQuery") && has.has("sysDomainGet"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a body could hold a registry method name at all, so the common case
|
||||
* -- mail, calendars, contacts from a session that may not administer -- skips
|
||||
* the parse. A method name is a JSON string starting `x:`, which appears in the
|
||||
* text as `"x:` unless written with a `\u` escape; a body with neither cannot
|
||||
* contain one, and is forwarded exactly as it came.
|
||||
*/
|
||||
export function mayNameRegistryMethod(raw: string): boolean {
|
||||
return raw.includes('"x:') || raw.includes("\\u");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a JMAP request body. On success, hands back the body to forward --
|
||||
* serialised from what was inspected, so the server can never be sent
|
||||
* something different from what was checked (a duplicate key, say, read one
|
||||
* way here and another way there).
|
||||
*/
|
||||
export function gateAdministration(raw: string): GateResult {
|
||||
if (!mayNameRegistryMethod(raw)) return { ok: true, body: raw };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, method: null };
|
||||
}
|
||||
const calls = (parsed as { methodCalls?: unknown } | null)?.methodCalls;
|
||||
if (!Array.isArray(calls)) return { ok: false, method: null };
|
||||
for (const call of calls) {
|
||||
const name = Array.isArray(call) ? call[0] : undefined;
|
||||
if (typeof name !== "string") return { ok: false, method: null };
|
||||
if (!name.startsWith("x:")) continue;
|
||||
const object = name.slice(2).split("/")[0] ?? "";
|
||||
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
|
||||
}
|
||||
return { ok: true, body: JSON.stringify(parsed) };
|
||||
}
|
||||
+56
-3
@@ -8,6 +8,7 @@ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
||||
import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
@@ -459,7 +460,11 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
*/
|
||||
const accountCtx = async (c: Context<Env>) => {
|
||||
const session = c.get("session");
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
// The account's own server. Without it, the first fetch after the cached
|
||||
// session expires goes to STALWART_URL -- which, for a domain mapped
|
||||
// elsewhere, either refuses the password or knows a different account by
|
||||
// the same name (#238).
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
return { authorization: session.authorization, session: upstream, username: session.username };
|
||||
};
|
||||
|
||||
@@ -633,6 +638,30 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
if (!ct.toLowerCase().startsWith("application/json")) {
|
||||
return c.json({ error: "unsupported_media_type" }, 415);
|
||||
}
|
||||
/*
|
||||
* For a session that may not administer -- administration switched off, or
|
||||
* a device not marked as the person's own -- the body is read and checked
|
||||
* before it goes anywhere. A session that may streams straight through as
|
||||
* it always has, and pays nothing for this.
|
||||
*/
|
||||
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
||||
if (!administrationAllowed(config.administration, session.remember)) {
|
||||
let raw: string;
|
||||
try {
|
||||
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
||||
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : "";
|
||||
} catch {
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
}
|
||||
const gate = gateAdministration(raw);
|
||||
if (!gate.ok) {
|
||||
if (!gate.method) return c.json({ error: "bad_request", message: "Not a JMAP request." }, 400);
|
||||
return config.administration
|
||||
? c.json({ error: "administration_needs_own_device", message: `Administration is only available when signed in on a device marked as your own (${gate.method}).` }, 403)
|
||||
: c.json({ error: "administration_disabled", message: `Administration is turned off on this installation (${gate.method}).` }, 403);
|
||||
}
|
||||
body = gate.body;
|
||||
}
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
@@ -642,7 +671,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
},
|
||||
body: c.req.raw.body,
|
||||
body,
|
||||
duplex: "half",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
@@ -824,7 +853,7 @@ function appPasswordName(c: Context): string {
|
||||
return `${config.appName} (${browser})`;
|
||||
}
|
||||
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null, permissions: [] }) {
|
||||
return {
|
||||
ihasmail: {
|
||||
appName: config.appName,
|
||||
@@ -838,6 +867,24 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
userLocale: info.locale,
|
||||
/** What the upstream server would tell us about itself. */
|
||||
server: { edition: info.edition },
|
||||
/**
|
||||
* Whether this session may administer: the installation offers it
|
||||
* (ADMINISTRATION) and the person signed in on a device marked as their own.
|
||||
*/
|
||||
administration: administrationAllowed(config.administration, session.remember),
|
||||
/**
|
||||
* An administrator signed in on a device not marked as their own, so the
|
||||
* menu can say why Administration is unavailable rather than lose it
|
||||
* without a word. Says only that the account administers, never what it
|
||||
* may do.
|
||||
*/
|
||||
administrationNeedsOwnDevice: config.administration && !session.remember && grantsAdministration(info.permissions),
|
||||
/**
|
||||
* The account's permissions on that server, so the client can offer
|
||||
* administration to those who have it. Stalwart still decides every call.
|
||||
* Withheld from a session that may not administer: nothing in it needs them.
|
||||
*/
|
||||
permissions: administrationAllowed(config.administration, session.remember) ? info.permissions : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -847,6 +894,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
* denylist: everything else it might set — cookies, auth challenges, CORS
|
||||
* grants — would be landing on *our* origin, where it means something else.
|
||||
*/
|
||||
/**
|
||||
* The largest JMAP request read into memory for the administration check.
|
||||
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way.
|
||||
*/
|
||||
const MAX_GATED_REQUEST = 16 * 1024 * 1024;
|
||||
|
||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -295,6 +295,13 @@ export const config = {
|
||||
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
||||
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
||||
imageProxy: bool("IMAGE_PROXY", true),
|
||||
/*
|
||||
* Whether ihasmail offers administration to accounts whose Stalwart role
|
||||
* allows it. Off means off: no menu, no permissions sent to the browser, and
|
||||
* the JMAP proxy refuses registry methods beyond the account's own -- see
|
||||
* adminGate.ts. Stalwart's own interface is unaffected either way.
|
||||
*/
|
||||
administration: bool("ADMINISTRATION", true),
|
||||
cookieName: env("COOKIE_NAME", "ihm_session"),
|
||||
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createDirectory, permissionsFor, type MockRole } from "./directory.js";
|
||||
|
||||
class Refused extends Error {
|
||||
constructor(readonly type: string, description?: string) { super(description ?? type); }
|
||||
}
|
||||
|
||||
const make = (role: MockRole) => createDirectory({ accountId: "a1", user: "[email protected]", locale: "en_US", role, fail: (t, d) => new Refused(t, d) });
|
||||
|
||||
/**
|
||||
* The mock stands in for a server that decides what each account may do, so
|
||||
* the client's administration can be developed against refusals as well as
|
||||
* successes. These pin the refusals.
|
||||
*/
|
||||
test("an ordinary user is refused the directory outright", () => {
|
||||
const dir = make("user");
|
||||
assert.throws(() => dir.handlers["x:Account/query"]!({}), (e: Refused) => e.type === "forbidden");
|
||||
assert.ok(!permissionsFor("user").some((p) => p.startsWith("sysAccountQuery")));
|
||||
});
|
||||
|
||||
test("helpdesk may read and edit but not create or delete", () => {
|
||||
const dir = make("helpdesk");
|
||||
const { ids } = dir.handlers["x:Account/query"]!({ filter: { "@type": "User" } }) as { ids: string[] };
|
||||
assert.ok(ids.length > 20);
|
||||
assert.throws(() => dir.handlers["x:Account/set"]!({ create: { n: { name: "x", domainId: "d1" } } }), (e: Refused) => e.type === "forbidden");
|
||||
assert.throws(() => dir.handlers["x:Account/set"]!({ destroy: [ids[0]] }), (e: Refused) => e.type === "forbidden");
|
||||
});
|
||||
|
||||
test("queries page, count and match text the way the client asks", () => {
|
||||
const dir = make("admin");
|
||||
const all = dir.handlers["x:Account/query"]!({ filter: { "@type": "User" }, calculateTotal: true }) as { ids: string[]; total: number };
|
||||
const page = dir.handlers["x:Account/query"]!({ filter: { "@type": "User" }, position: 10, limit: 5, calculateTotal: true }) as { ids: string[]; total: number };
|
||||
assert.equal(page.total, all.total);
|
||||
assert.deepEqual(page.ids, all.ids.slice(10, 15));
|
||||
const ada = dir.handlers["x:Account/query"]!({ filter: { "@type": "User", text: "lovelace" } }) as { ids: string[] };
|
||||
assert.equal(ada.ids.length, 1);
|
||||
assert.throws(() => dir.handlers["x:Account/query"]!({ filter: { operator: "OR", conditions: [] } }), (e: Refused) => e.type === "unsupportedFilter");
|
||||
});
|
||||
|
||||
test("an address already used as an alias cannot be taken", () => {
|
||||
const dir = make("admin");
|
||||
const res = dir.handlers["x:Account/set"]!({ create: { n: { "@type": "User", name: "postmaster", domainId: "d1", credentials: { "0": { "@type": "Password", secret: "long enough secret" } }, roles: { "@type": "User" } } } }) as { notCreated?: Record<string, { type: string }> };
|
||||
assert.equal(res.notCreated?.n?.type, "primaryKeyViolation");
|
||||
});
|
||||
|
||||
test("a password is set through its credential's pointer, and a weak one is refused", () => {
|
||||
const dir = make("admin");
|
||||
const set = dir.handlers["x:Account/set"]!;
|
||||
assert.equal((set({ update: { a1: { "credentials/0/secret": "short" } } }) as { notUpdated?: Record<string, { properties: string[] }> }).notUpdated?.a1?.properties[0], "secret");
|
||||
assert.deepEqual((set({ update: { a1: { "credentials/0/secret": "a much longer secret" } } }) as { updated: object }).updated, { a1: null });
|
||||
const got = dir.handlers["x:Account/get"]!({ ids: ["a1"], properties: ["credentials"] }) as { list: Array<{ credentials: Record<string, { secret: string }> }> };
|
||||
assert.equal(got.list[0]!.credentials["0"]!.secret, "[********]", "never echoed back");
|
||||
});
|
||||
|
||||
test("a grant the caller does not hold is refused", () => {
|
||||
const dir = make("helpdesk");
|
||||
const res = dir.handlers["x:Account/set"]!({ update: { u101: { roles: { "@type": "Admin" } } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(res.notUpdated?.u101?.type, "forbidden");
|
||||
});
|
||||
|
||||
test("an administrator can delete an account, and a group with members is kept", () => {
|
||||
const dir = make("admin");
|
||||
const set = dir.handlers["x:Account/set"]!;
|
||||
assert.deepEqual((set({ destroy: ["u101"] }) as { destroyed: string[] }).destroyed, ["u101"]);
|
||||
assert.equal((set({ destroy: ["g1"] }) as { notDestroyed?: Record<string, { type: string }> }).notDestroyed?.g1?.type, "objectIsLinked");
|
||||
});
|
||||
|
||||
test("a domain in use is kept, and names what uses it", () => {
|
||||
const dir = make("admin");
|
||||
const set = dir.handlers["x:Domain/set"]!;
|
||||
const res = set({ destroy: ["d1"] }) as { notDestroyed?: Record<string, { type: string; linkedObjects: Array<{ object: string }> }> };
|
||||
assert.equal(res.notDestroyed?.d1?.type, "objectIsLinked");
|
||||
const kinds = new Set(res.notDestroyed?.d1?.linkedObjects.map((o) => o.object));
|
||||
assert.deepEqual([...kinds].sort(), ["Account", "DkimSignature"]);
|
||||
});
|
||||
|
||||
test("an unused domain goes once its keys do", () => {
|
||||
const dir = make("admin");
|
||||
const created = dir.handlers["x:Domain/set"]!({ create: { n: { name: "fresh.example.net" } } }) as { created: Record<string, { id: string }> };
|
||||
const id = created.created.n!.id;
|
||||
const keys = dir.handlers["x:DkimSignature/query"]!({ filter: { domainId: id } }) as { ids: string[] };
|
||||
assert.equal(keys.ids.length, 1, "automatic DKIM makes a key straight away");
|
||||
assert.equal((dir.handlers["x:Domain/set"]!({ destroy: [id] }) as { notDestroyed?: object }).notDestroyed !== undefined, true);
|
||||
dir.handlers["x:DkimSignature/set"]!({ destroy: keys.ids });
|
||||
assert.deepEqual((dir.handlers["x:Domain/set"]!({ destroy: [id] }) as { destroyed: string[] }).destroyed, [id]);
|
||||
});
|
||||
|
||||
test("a domain's zone file is computed on read, with long keys split as the server splits them", () => {
|
||||
const dir = make("admin");
|
||||
const got = dir.handlers["x:Domain/get"]!({ ids: ["d1"], properties: ["name", "dnsZoneFile"] }) as { list: Array<{ dnsZoneFile: string }> };
|
||||
const zone = got.list[0]!.dnsZoneFile;
|
||||
assert.match(zone, /IN MX 10 /);
|
||||
assert.match(zone, /_domainkey\.example\.com\. IN TXT \(\n {4}"/);
|
||||
});
|
||||
|
||||
test("a filter on a name the registry does not index is refused, as the live server refuses it", () => {
|
||||
const dir = make("admin");
|
||||
// Seen on a live 0.16 server: "x:Account/query: unsupportedFilter - type".
|
||||
assert.throws(() => dir.handlers["x:Account/query"]!({ filter: { type: "User" } }), (e: Refused) => e.type === "unsupportedFilter" && e.message === "type");
|
||||
assert.doesNotThrow(() => dir.handlers["x:Account/query"]!({ filter: { "@type": "Group", domainId: "d1", text: "x" } }));
|
||||
});
|
||||
|
||||
test("the domain validators refuse what the live server refused, in its words", () => {
|
||||
const dir = make("admin");
|
||||
const set = dir.handlers["x:Domain/set"]!;
|
||||
const created = set({ create: { n: { name: "admin-test.example" } } }) as { notCreated?: Record<string, { type: string; description: string }> };
|
||||
assert.deepEqual([created.notCreated?.n?.type, created.notCreated?.n?.description], ["invalidPatch", "Invalid domain name"]);
|
||||
const updated = set({ update: { d2: { catchAllAddress: "postmaster" } } }) as { notUpdated?: Record<string, { type: string; description: string }> };
|
||||
assert.deepEqual([updated.notUpdated?.d2?.type, updated.notUpdated?.d2?.description], ["invalidPatch", "Invalid email address"]);
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Enough of Stalwart 0.16's directory registry to develop administration
|
||||
* against: `x:Account`, `x:Domain` and `x:Role`, gated by permission names the
|
||||
* way the real server gates them.
|
||||
*
|
||||
* Shapes follow the 0.16.22 source rather than the documentation, which has
|
||||
* been wrong about both before:
|
||||
*
|
||||
* - a `List<T>` (credentials, aliases) is an object keyed by index -- `{"0": …}`
|
||||
* -- and a `Set` (memberGroupIds, enabledPermissions) is `{"id": true}`;
|
||||
* - an account's `name` is the local part only, and it lives on a domain by id;
|
||||
* - secrets come back masked, and a new one is written through the password
|
||||
* credential's own pointer, `credentials/<index>/secret`;
|
||||
* - `x:Account/query` understands AND and nothing else.
|
||||
*
|
||||
* What it does not reproduce is tenancy: every caller sees every record. The
|
||||
* real server scopes a tenant administrator's queries, and nothing in the client
|
||||
* relies on seeing more or less than it is given.
|
||||
*
|
||||
* MOCK_ROLE picks who the demo user is: `admin` (the default), `tenant-admin`,
|
||||
* `helpdesk` (a custom role that may view and edit accounts but not create or
|
||||
* delete them) or `user`.
|
||||
*/
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
export type MockRole = "admin" | "tenant-admin" | "helpdesk" | "user";
|
||||
|
||||
const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const;
|
||||
const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`));
|
||||
|
||||
/** A few of the ordinary ones, so the list looks like what a server sends. */
|
||||
const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailSet", "jmapMailboxGet", "sysAccountSettingsGet"];
|
||||
|
||||
export function permissionsFor(role: MockRole): string[] {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), "impersonate"];
|
||||
case "tenant-admin":
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer")];
|
||||
case "helpdesk":
|
||||
return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
|
||||
default:
|
||||
return USER_PERMISSIONS;
|
||||
}
|
||||
}
|
||||
|
||||
export function mockRole(raw: string | undefined): MockRole {
|
||||
return raw === "tenant-admin" || raw === "helpdesk" || raw === "user" ? raw : "admin";
|
||||
}
|
||||
|
||||
const MASKED = "[********]";
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
interface Options {
|
||||
/** The demo user's JMAP account id, which is also its registry id. */
|
||||
accountId: string;
|
||||
/** The demo user's address. */
|
||||
user: string;
|
||||
locale: string;
|
||||
role: MockRole;
|
||||
/** Build the error a method fails with; the mock server owns the type. */
|
||||
fail: (type: string, description?: string) => Error;
|
||||
}
|
||||
|
||||
export function createDirectory(opts: Options) {
|
||||
const permissions = new Set(permissionsFor(opts.role));
|
||||
const [userLocal, userDomain] = splitAddress(opts.user);
|
||||
let counter = 100;
|
||||
|
||||
const managed = (dns: boolean, dkim: boolean, certs: boolean) => ({
|
||||
dnsManagement: dns ? { "@type": "Automatic", dnsServerId: "ns1", origin: null, publishRecords: {} } : { "@type": "Manual" },
|
||||
dkimManagement: dkim ? { "@type": "Automatic", algorithms: { Dkim1Ed25519Sha256: true, Dkim1RsaSha256: true }, selectorTemplate: "v{version}-{algorithm}-{date-%Y%m%d}" } : { "@type": "Manual" },
|
||||
certificateManagement: certs ? { "@type": "Automatic", acmeProviderId: "acme1", subjectAlternativeNames: {} } : { "@type": "Manual" },
|
||||
});
|
||||
const domain = (id: string, name: string, extra: Obj = {}): Obj => ({
|
||||
id, name, aliases: {}, isEnabled: true, createdAt: "2026-06-01T09:00:00Z", description: null, logo: null,
|
||||
...managed(false, true, false), memberTenantId: null, directoryId: null, catchAllAddress: null,
|
||||
subAddressing: { "@type": "Enabled" }, allowRelaying: false, reportAddressUri: "mailto:postmaster", allowScimProvisioning: false, ...extra,
|
||||
});
|
||||
const domains: Obj[] = [
|
||||
domain("d1", userDomain, { ...managed(true, true, true), aliases: { [`mail.${userDomain}`]: true }, description: "Main domain" }),
|
||||
domain("d2", userDomain === "example.org" ? "example.net" : "example.org", { catchAllAddress: `postmaster@${userDomain}` }),
|
||||
domain("d3", "old-brand.example", { ...managed(false, false, false), description: "No longer used", subAddressing: { "@type": "Custom", customRule: "..." } }),
|
||||
];
|
||||
const dkimKeys: Obj[] = [
|
||||
{ id: "k1", "@type": "Dkim1Ed25519Sha256", domainId: "d1", selector: "v1-ed25519-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
||||
{ id: "k2", "@type": "Dkim1RsaSha256", domainId: "d1", selector: "v1-rsa-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
||||
{ id: "k3", "@type": "Dkim1Ed25519Sha256", domainId: "d2", selector: "v1-ed25519-20260710", stage: "active", createdAt: "2026-07-10T09:00:00Z", nextTransitionAt: null, memberTenantId: null },
|
||||
];
|
||||
/** What Stalwart's BIND serialiser writes, including a TXT long enough to be split. */
|
||||
const zoneFile = (d: Obj): string => {
|
||||
const n = String(d.name);
|
||||
const lines = [
|
||||
`${n}. IN MX 10 mail.${userDomain}.`,
|
||||
`${n}. IN TXT "v=spf1 mx ra=postmaster -all"`,
|
||||
];
|
||||
for (const k of dkimKeys.filter((k) => k.domainId === d.id && k.stage !== "retired")) {
|
||||
if (String(k["@type"]).includes("Rsa")) {
|
||||
const p = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA" + "x".repeat(300) + "IDAQAB";
|
||||
const txt = `v=DKIM1; k=rsa; h=sha256; p=${p}`;
|
||||
lines.push(`${k.selector}._domainkey.${n}. IN TXT (`, ...(txt.match(/.{1,255}/g) ?? []).map((c) => ` "${c}"`), ")");
|
||||
} else {
|
||||
lines.push(`${k.selector}._domainkey.${n}. IN TXT "v=DKIM1; k=ed25519; h=sha256; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="`);
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
`_dmarc.${n}. IN TXT "v=DMARC1; p=reject; rua=mailto:postmaster@${n}; ruf=mailto:postmaster@${n}"`,
|
||||
`_jmap._tcp.${n}. IN SRV 0 1 443 mail.${userDomain}.`,
|
||||
`_submissions._tcp.${n}. IN SRV 0 1 465 mail.${userDomain}.`,
|
||||
`_imaps._tcp.${n}. IN SRV 0 1 993 mail.${userDomain}.`,
|
||||
`mta-sts.${n}. IN CNAME mail.${userDomain}.`,
|
||||
`_mta-sts.${n}. IN TXT "v=STSv1; id=16837364213434767412"`,
|
||||
`_smtp._tls.${n}. IN TXT "v=TLSRPTv1; rua=mailto:postmaster@${n}"`,
|
||||
`autoconfig.${n}. IN CNAME mail.${userDomain}.`,
|
||||
`${n}. IN CAA 0 issue "letsencrypt.org"`,
|
||||
);
|
||||
return lines.join("\n") + "\n";
|
||||
};
|
||||
|
||||
const roles: Obj[] = [
|
||||
{ id: "r1", description: "User", enabledPermissions: flags(USER_PERMISSIONS), disabledPermissions: {}, roleIds: {} },
|
||||
{ id: "r2", description: "Helpdesk", enabledPermissions: flags(permissionsFor("helpdesk").filter((p) => p.startsWith("sys"))), disabledPermissions: {}, roleIds: { r1: true } },
|
||||
{ id: "r3", description: "Directory manager", enabledPermissions: flags(all("Account")), disabledPermissions: {}, roleIds: { r1: true } },
|
||||
];
|
||||
|
||||
const ownRoles = opts.role === "admin" || opts.role === "tenant-admin" ? { "@type": "Admin" } : opts.role === "helpdesk" ? { "@type": "Custom", roleIds: { r2: true } } : { "@type": "User" };
|
||||
|
||||
const accounts: Obj[] = [];
|
||||
const user = (o: { id?: string; name: string; domain?: string; description: string; roles?: Obj; used?: number; quota?: number; aliases?: string[]; groups?: string[]; password?: boolean }) => {
|
||||
const domainId = o.domain === "d2" ? "d2" : "d1";
|
||||
const row: Obj = {
|
||||
id: o.id ?? `u${counter++}`,
|
||||
"@type": "User",
|
||||
name: o.name,
|
||||
domainId,
|
||||
description: o.description,
|
||||
credentials: o.password === false ? {} : { "0": { "@type": "Password", credentialId: "0", secret: MASKED, otpAuth: null, expiresAt: null, allowedIps: {} } },
|
||||
createdAt: new Date(Date.now() - counter * 86_400_000).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||
memberGroupIds: flags(o.groups ?? []),
|
||||
memberTenantId: null,
|
||||
roles: o.roles ?? { "@type": "User" },
|
||||
permissions: { "@type": "Inherit" },
|
||||
quotas: o.quota ? { maxDiskQuota: o.quota * GIB } : {},
|
||||
usedDiskQuota: Math.round((o.used ?? 0) * GIB),
|
||||
aliases: Object.fromEntries((o.aliases ?? []).map((name, i) => [String(i), { enabled: true, name, domainId, description: null }])),
|
||||
locale: opts.locale,
|
||||
timeZone: null,
|
||||
};
|
||||
accounts.push(row);
|
||||
return row;
|
||||
};
|
||||
const group = (id: string, name: string, description: string) =>
|
||||
accounts.push({ id, "@type": "Group", name, domainId: "d1", description, memberTenantId: null, roles: { "@type": "User" }, permissions: { "@type": "Inherit" }, quotas: {}, usedDiskQuota: 0, aliases: {} });
|
||||
|
||||
group("g1", "support", "Support");
|
||||
group("g2", "office", "Office");
|
||||
user({ id: opts.accountId, name: userLocal, description: "Demo User", roles: ownRoles, used: 1.4, quota: 10, aliases: ["postmaster"], groups: ["g1"] });
|
||||
user({ name: "ada", domain: "d2", description: "Ada Lovelace", used: 3.2, quota: 5, groups: ["g2"] });
|
||||
user({ name: "grace", domain: "d2", description: "Grace Hopper", used: 4.7, quota: 5, groups: ["g2"] });
|
||||
user({ name: "alan", domain: "d2", description: "Alan Turing", roles: { "@type": "Custom", roleIds: { r2: true } }, used: 0.8, quota: 5, groups: ["g1"] });
|
||||
user({ name: "margaret", description: "Margaret Hamilton", roles: { "@type": "Admin" }, used: 2.1, quota: 20 });
|
||||
user({ name: "katherine", description: "Katherine Johnson", roles: { "@type": "Custom", roleIds: { r3: true } }, used: 0.4, quota: 5 });
|
||||
user({ name: "sso.only", description: "Signs in with SSO", password: false, used: 0.1 });
|
||||
const people = ["Edsger Dijkstra", "Barbara Liskov", "Donald Knuth", "Frances Allen", "John Backus", "Radia Perlman", "Ken Thompson", "Hedy Lamarr", "Dennis Ritchie", "Karen Spärck Jones", "Tim Berners-Lee", "Sophie Wilson", "Niklaus Wirth", "Jean Sammet", "Leslie Lamport", "Mary Kenneth Keller", "Tony Hoare", "Evelyn Berezin", "Butler Lampson", "Shafi Goldwasser", "Whitfield Diffie", "Adele Goldberg", "Vint Cerf", "Anita Borg", "Bob Kahn", "Lynn Conway", "Charles Babbage", "Annie Easley"];
|
||||
people.forEach((description, i) => {
|
||||
const name = description.toLowerCase().split(" ")[0]!.normalize("NFD").replace(/[^a-z]/g, "");
|
||||
user({ name, domain: i % 3 === 0 ? "d2" : "d1", description, used: (i % 7) * 0.6, quota: i % 4 === 0 ? 0 : 5 });
|
||||
});
|
||||
|
||||
const demand = (perm: string) => {
|
||||
if (!permissions.has(perm)) throw opts.fail("forbidden", `You do not have the ${perm} permission.`);
|
||||
};
|
||||
const domainName = (id: unknown) => domains.find((d) => d.id === id)?.name as string | undefined;
|
||||
const addressOf = (o: Obj) => `${o.name}@${domainName(o.domainId) ?? "invalid"}`;
|
||||
/** Every address in use, primary and alias, across accounts. */
|
||||
const addressTaken = (address: string, except?: string) =>
|
||||
accounts.some((a) => a.id !== except && (addressOf(a) === address || Object.values((a.aliases as Obj) ?? {}).some((al) => `${(al as Obj).name}@${domainName((al as Obj).domainId)}` === address)));
|
||||
|
||||
const view = (o: Obj, properties: unknown): Obj => {
|
||||
const full: Obj = { ...o };
|
||||
if (accounts.includes(o)) full.emailAddress = addressOf(o);
|
||||
if (domains.includes(o)) full.dnsZoneFile = zoneFile(o);
|
||||
if (full.credentials) {
|
||||
full.credentials = Object.fromEntries(Object.entries(full.credentials as Obj).map(([k, c]) => [k, { ...(c as Obj), secret: MASKED }]));
|
||||
}
|
||||
if (!Array.isArray(properties)) return full;
|
||||
const out: Obj = { id: o.id };
|
||||
for (const p of properties as string[]) if (p in full) out[p] = full[p];
|
||||
return out;
|
||||
};
|
||||
|
||||
const get = (list: Obj[], perm: string) => (a: Obj) => {
|
||||
demand(perm);
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
const found = ids ? list.filter((x) => ids.includes(x.id as string)) : list;
|
||||
return { accountId: opts.accountId, state: "1", list: found.map((x) => view(x, a.properties)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||
};
|
||||
|
||||
/**
|
||||
* A query, filtered only on what the real server indexes for that object.
|
||||
* Any other name is refused the way Stalwart refuses it -- `unsupportedFilter`
|
||||
* with the name as the whole description -- because a mock that took
|
||||
* `{"type": "User"}` let exactly that ship, and the live server answers it
|
||||
* with "unsupportedFilter - type".
|
||||
*/
|
||||
const query = (list: () => Obj[], perm: string, filterable: string[], match: (o: Obj, filter: Obj) => boolean) => (a: Obj) => {
|
||||
demand(perm);
|
||||
const filter = (a.filter as Obj | undefined) ?? {};
|
||||
if ("operator" in filter) throw opts.fail("unsupportedFilter", "Only AND is supported in filters");
|
||||
const unknown = Object.keys(filter).find((k) => !filterable.includes(k));
|
||||
if (unknown) throw opts.fail("unsupportedFilter", unknown);
|
||||
// Stalwart's default order is newest first, by id.
|
||||
const rows = list().filter((o) => match(o, filter)).sort((x, y) => String(y.id).localeCompare(String(x.id), undefined, { numeric: true }));
|
||||
const position = Math.max(0, Number(a.position ?? 0));
|
||||
const limit = a.limit == null ? rows.length : Number(a.limit);
|
||||
return {
|
||||
accountId: opts.accountId,
|
||||
queryState: "1",
|
||||
canCalculateChanges: false,
|
||||
position,
|
||||
ids: rows.slice(position, position + limit).map((o) => o.id),
|
||||
...(a.calculateTotal ? { total: rows.length } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const matchText = (o: Obj, text: unknown) => {
|
||||
if (typeof text !== "string" || !text.trim()) return true;
|
||||
const needle = text.trim().toLowerCase();
|
||||
return [o.name, o.description, addressOf(o)].some((v) => typeof v === "string" && v.toLowerCase().includes(needle));
|
||||
};
|
||||
|
||||
const setError = (type: string, description: string, properties?: string[]) => ({ type, description, ...(properties ? { properties } : {}) });
|
||||
|
||||
/** The password checks, roughly as strict as a default Stalwart. */
|
||||
const weakPassword = (secret: unknown) => (typeof secret !== "string" || secret.length < 8 ? "Password must be at least 8 characters long." : null);
|
||||
|
||||
/** Stalwart checks a grant against the caller's own permissions. */
|
||||
const grantRefused = (roles: unknown): string | null => {
|
||||
const r = roles as Obj | undefined;
|
||||
if (!r) return null;
|
||||
if (r["@type"] === "Admin" && opts.role !== "admin" && opts.role !== "tenant-admin") return "You are not authorized to grant permissions: administrator.";
|
||||
if (r["@type"] === "Custom") {
|
||||
for (const id of Object.keys((r.roleIds as Obj) ?? {})) {
|
||||
const role = roles_(id);
|
||||
if (!role) return "Role does not exist.";
|
||||
const missing = Object.keys((role.enabledPermissions as Obj) ?? {}).filter((p) => !permissions.has(p));
|
||||
if (missing.length) return `You are not authorized to grant permissions: ${missing.join(", ")}.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const roles_ = (id: string) => roles.find((r) => r.id === id);
|
||||
|
||||
const handlers: Record<string, (a: Obj) => Obj> = {
|
||||
"x:Account/get": get(accounts, "sysAccountGet"),
|
||||
"x:Account/query": query(() => accounts, "sysAccountQuery", ["text", "@type", "domainId", "externalId", "memberGroupIds", "memberTenantId", "name"], (o, f) =>
|
||||
(f["@type"] === undefined || o["@type"] === f["@type"]) && (f.domainId === undefined || o.domainId === f.domainId) && matchText(o, f.text) && matchText(o, f.name)),
|
||||
"x:Account/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Obj = {};
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
demand("sysAccountCreate");
|
||||
const o = { ...(raw as Obj) };
|
||||
if (typeof o.name !== "string" || !/^[a-z0-9._-]+$/i.test(o.name)) { notCreated[cid] = setError("invalidProperties", "Invalid account name.", ["name"]); continue; }
|
||||
if (!domainName(o.domainId)) { notCreated[cid] = setError("invalidForeignKey", "Domain does not exist.", ["domainId"]); continue; }
|
||||
if (addressTaken(`${o.name}@${domainName(o.domainId)}`)) { notCreated[cid] = setError("primaryKeyViolation", "An account or alias with this email address already exists."); continue; }
|
||||
const refused = grantRefused(o.roles);
|
||||
if (refused) { notCreated[cid] = setError("forbidden", refused); continue; }
|
||||
const password = Object.values((o.credentials as Obj) ?? {})[0] as Obj | undefined;
|
||||
const weak = password ? weakPassword(password.secret) : null;
|
||||
if (weak) { notCreated[cid] = setError("invalidProperties", weak, ["secret"]); continue; }
|
||||
const id = `u${counter++}`;
|
||||
accounts.push({ memberGroupIds: {}, aliases: {}, quotas: {}, permissions: { "@type": "Inherit" }, ...o, id, memberTenantId: null, usedDiskQuota: 0, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), locale: opts.locale, timeZone: null });
|
||||
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
demand("sysAccountUpdate");
|
||||
const target = accounts.find((x) => x.id === id);
|
||||
if (!target) { notUpdated[id] = setError("notFound", "Account not found."); continue; }
|
||||
const patch = raw as Obj;
|
||||
const next = structuredClone(target);
|
||||
let failure: Obj | null = null;
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
if (path === "id" || path === "@type" || path === "usedDiskQuota" || path === "emailAddress") { failure = setError("invalidProperties", `Property ${path} cannot be changed.`, [path]); break; }
|
||||
if (path.endsWith("/secret")) {
|
||||
const weak = weakPassword(value);
|
||||
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
|
||||
}
|
||||
if (path.startsWith("credentials/") && value && typeof value === "object") {
|
||||
const weak = weakPassword((value as Obj).secret);
|
||||
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
|
||||
}
|
||||
setPointer(next, path, value);
|
||||
}
|
||||
if (!failure && ("roles" in patch || "permissions" in patch)) {
|
||||
const refused = grantRefused(next.roles);
|
||||
if (refused) failure = setError("forbidden", refused);
|
||||
}
|
||||
if (!failure) {
|
||||
for (const al of Object.values((next.aliases as Obj) ?? {})) {
|
||||
const address = `${(al as Obj).name}@${domainName((al as Obj).domainId)}`;
|
||||
if (!domainName((al as Obj).domainId)) { failure = setError("invalidForeignKey", "Domain does not exist.", ["aliases"]); break; }
|
||||
if (addressTaken(address, id)) { failure = setError("primaryKeyViolation", "An account or alias with this email address already exists."); break; }
|
||||
}
|
||||
}
|
||||
if (failure) { notUpdated[id] = failure; continue; }
|
||||
// Secrets are stored hashed; the mock just stops echoing them.
|
||||
for (const c of Object.values((next.credentials as Obj) ?? {})) (c as Obj).secret = MASKED;
|
||||
Object.assign(target, next);
|
||||
updated[id] = null;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysAccountDestroy");
|
||||
const i = accounts.findIndex((x) => x.id === id);
|
||||
if (i < 0) { notDestroyed[id] = setError("notFound", "Account not found."); continue; }
|
||||
if (accounts[i]!["@type"] === "Group" && accounts.some((x) => (x.memberGroupIds as Obj | undefined)?.[id])) {
|
||||
notDestroyed[id] = { ...setError("objectIsLinked", "Group still has members."), linkedObjects: {} };
|
||||
continue;
|
||||
}
|
||||
accounts.splice(i, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||
},
|
||||
"x:Domain/get": get(domains, "sysDomainGet"),
|
||||
"x:Domain/query": query(() => domains, "sysDomainQuery", ["text", "aliases", "memberTenantId", "name"], (o, f) => matchText(o, f.text) && matchText(o, f.name)),
|
||||
"x:Domain/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Obj = {};
|
||||
const taken = (name: string, except?: string) => domains.some((d) => d.id !== except && (d.name === name || Object.keys((d.aliases as Obj) ?? {}).includes(name)));
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
demand("sysDomainCreate");
|
||||
const o = raw as Obj;
|
||||
const name = String(o.name ?? "");
|
||||
// Live on 2026-09-13: a reserved TLD is refused by the registry's
|
||||
// domain validator, as invalidPatch with the validator's own words.
|
||||
if (!/^([a-z0-9-]+\.)+[a-z0-9-]{2,}$/.test(name) || /\.(example|test|invalid|localhost)$/.test(name)) { notCreated[cid] = setError("invalidPatch", "Invalid domain name", ["name"]); continue; }
|
||||
if (taken(name)) { notCreated[cid] = setError("primaryKeyViolation", "A domain with this name already exists.", ["name"]); continue; }
|
||||
const id = `d${counter++}`;
|
||||
domains.push(domain(id, name, { ...o, id, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z") }));
|
||||
// Automatic DKIM, the default, makes its keys straight away.
|
||||
dkimKeys.push({ id: `k${counter++}`, "@type": "Dkim1Ed25519Sha256", domainId: id, selector: "v1-ed25519-20260913", stage: "active", createdAt: new Date().toISOString(), nextTransitionAt: null, memberTenantId: null });
|
||||
created[cid] = { id };
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
demand("sysDomainUpdate");
|
||||
const target = domains.find((d) => d.id === id);
|
||||
if (!target) { notUpdated[id] = setError("notFound", "Domain not found."); continue; }
|
||||
const next = structuredClone(target);
|
||||
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
||||
// Live on 2026-09-13: a catch-all that is not a whole address.
|
||||
if (typeof next.catchAllAddress === "string" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(next.catchAllAddress)) { notUpdated[id] = setError("invalidPatch", "Invalid email address", ["catchAllAddress"]); continue; }
|
||||
const clash = Object.keys((next.aliases as Obj) ?? {}).find((alias) => alias === next.name || taken(alias, id));
|
||||
if (clash) { notUpdated[id] = setError("primaryKeyViolation", `The name ${clash} is already in use.`, ["aliases"]); continue; }
|
||||
Object.assign(target, next);
|
||||
updated[id] = null;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysDomainDestroy");
|
||||
const i = domains.findIndex((d) => d.id === id);
|
||||
if (i < 0) { notDestroyed[id] = setError("notFound", "Domain not found."); continue; }
|
||||
const linked = [
|
||||
...accounts.filter((x) => x.domainId === id || Object.values((x.aliases as Obj) ?? {}).some((al) => (al as Obj).domainId === id)).map((x) => ({ object: "Account", id: x.id })),
|
||||
...dkimKeys.filter((k) => k.domainId === id).map((k) => ({ object: "DkimSignature", id: k.id })),
|
||||
];
|
||||
if (linked.length) { notDestroyed[id] = { ...setError("objectIsLinked", "Object is linked to other objects."), linkedObjects: linked }; continue; }
|
||||
domains.splice(i, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||
},
|
||||
"x:DkimSignature/get": get(dkimKeys, "sysDkimSignatureGet"),
|
||||
"x:DkimSignature/query": query(() => dkimKeys, "sysDkimSignatureQuery", ["domainId", "memberTenantId"], (o, f) => f.domainId === undefined || o.domainId === f.domainId),
|
||||
"x:DkimSignature/set": (a) => {
|
||||
const destroyed: string[] = [];
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysDkimSignatureDestroy");
|
||||
const i = dkimKeys.findIndex((k) => k.id === id);
|
||||
if (i >= 0) { dkimKeys.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
if (a.create) throw opts.fail("forbidden", "The mock does not generate DKIM keys; automatic management does that.");
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created: {}, updated: {}, destroyed };
|
||||
},
|
||||
"x:DnsServer/get": (a) => {
|
||||
demand("sysDnsServerGet");
|
||||
return { accountId: opts.accountId, state: "1", list: ((a.ids as string[]) ?? ["ns1"]).filter((id) => id === "ns1").map((id) => ({ id, "@type": "Cloudflare", description: "Cloudflare (main zone)" })), notFound: [] };
|
||||
},
|
||||
"x:Role/get": get(roles, "sysRoleGet"),
|
||||
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => matchText(o, f.description)),
|
||||
};
|
||||
|
||||
return { handlers, permissions: [...permissions], accounts };
|
||||
}
|
||||
|
||||
function flags(names: string[]): Obj {
|
||||
return Object.fromEntries(names.map((n) => [n, true]));
|
||||
}
|
||||
|
||||
function splitAddress(address: string): [string, string] {
|
||||
const at = address.lastIndexOf("@");
|
||||
return at < 0 ? [address, "example.com"] : [address.slice(0, at), address.slice(at + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one JMAP patch entry. A path walks into nested objects; `null` at the
|
||||
* end removes the key, which is how an alias or a quota is taken away.
|
||||
*/
|
||||
function setPointer(obj: Obj, path: string, value: unknown): void {
|
||||
const parts = path.split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
|
||||
let node = obj;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!node[part] || typeof node[part] !== "object") node[part] = {};
|
||||
node = node[part] as Obj;
|
||||
}
|
||||
const last = parts[parts.length - 1]!;
|
||||
if (value === null) delete node[last];
|
||||
else node[last] = value;
|
||||
}
|
||||
+23
-11
@@ -6,9 +6,10 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
import { createDirectory, mockRole } from "./directory.js";
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
@@ -885,6 +886,15 @@ function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Who the demo user is, for administration. See mock/directory.ts. */
|
||||
const directory = createDirectory({
|
||||
accountId: ACCOUNT,
|
||||
user: USER,
|
||||
locale: MOCK_LOCALE,
|
||||
role: mockRole(process.env.MOCK_ROLE),
|
||||
fail: (type, description) => new MethodError(type, description),
|
||||
});
|
||||
|
||||
const handlers: Record<string, Handler> = {
|
||||
// 0.16 exposes the account locale here, under a permission ordinary users
|
||||
// actually have (unlike x:Account below, which needs sysAccountGet).
|
||||
@@ -893,12 +903,10 @@ const handlers: Record<string, Handler> = {
|
||||
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
|
||||
},
|
||||
// Stalwart's directory extension - the client reads the account locale from here.
|
||||
"x:Account/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? [ACCOUNT];
|
||||
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
|
||||
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
|
||||
},
|
||||
// Stalwart's directory registry: accounts, domains and roles, behind the
|
||||
// same permissions as the real thing. The locale fallback reads x:Account
|
||||
// too, and is refused here exactly when a real server would refuse it.
|
||||
...directory.handlers,
|
||||
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
|
||||
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
||||
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||
@@ -1255,15 +1263,17 @@ const handlers: Record<string, Handler> = {
|
||||
"CalendarEvent/get": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
if (!ids) return genericGet(list)(a);
|
||||
const properties = a.properties as string[] | null | undefined;
|
||||
// With no ids every event comes back under its stored id, none synthetic.
|
||||
if (!ids) return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => eventGetView(x, false, properties)), notFound: [] };
|
||||
const found: Obj[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of ids) {
|
||||
const resolved = resolveEvent(list, id);
|
||||
if (!resolved) { notFound.push(id); continue; }
|
||||
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
|
||||
found.push(resolved.occ ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties));
|
||||
}
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound };
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found, notFound };
|
||||
},
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||
@@ -1303,6 +1313,8 @@ const handlers: Record<string, Handler> = {
|
||||
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
||||
},
|
||||
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
||||
// An empty `properties` list returns `id` alone, which `pick` already does.
|
||||
// 0.16.22 made Stalwart agree; through 0.16.21 it returned every property.
|
||||
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
||||
"ContactCard/set": genericSet(cards, "cc"),
|
||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
@@ -1413,7 +1425,7 @@ export const server = createServer(async (req, res) => {
|
||||
// The account info endpoint; the only place a server reports its edition.
|
||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
||||
return res.end(JSON.stringify({ permissions: directory.permissions, edition: "oss", locale: MOCK_LOCALE }));
|
||||
}
|
||||
if (url.pathname === "/jmap/" && req.method === "POST") {
|
||||
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||
|
||||
/**
|
||||
* The mock expands recurrences so that per-occurrence editing can be developed
|
||||
@@ -98,6 +98,54 @@ describe("occurrenceView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventGetView", () => {
|
||||
/*
|
||||
* What 0.16.22 changed in `CalendarEvent/get`, read from its source and the
|
||||
* tests that came with it (`tests/src/jmap/calendar/event.rs` and
|
||||
* `instance.rs`).
|
||||
*/
|
||||
it("reports no base for an event read by its stored id", () => {
|
||||
// 0.16.21 answered with the event's own id here.
|
||||
assert.deepEqual(eventGetView(oneOff(), false, ["id", "baseEventId"]), { id: "ev2", baseEventId: null });
|
||||
assert.equal(eventGetView(series(), false, ["baseEventId"]).baseEventId, null);
|
||||
});
|
||||
|
||||
it("still gives a one-off read through its synthetic id a base", () => {
|
||||
// An expanded query hands a one-off a synthetic id, so this has not
|
||||
// changed: `baseEventId` is still no evidence of a series.
|
||||
const base = oneOff();
|
||||
const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!), true, ["baseEventId"]);
|
||||
assert.equal(view.baseEventId, "ev2");
|
||||
});
|
||||
|
||||
it("answers null for the rule and overrides named on an occurrence", () => {
|
||||
const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
||||
const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!), true,
|
||||
["recurrenceId", "recurrenceRule", "recurrenceOverrides"]);
|
||||
assert.deepEqual(view, { id: "ev1-r20260908T090000", recurrenceId: "2026-09-08T09:00:00", recurrenceRule: null, recurrenceOverrides: null });
|
||||
});
|
||||
|
||||
it("leaves the rule on the series itself alone", () => {
|
||||
assert.deepEqual(eventGetView(series(), false, ["recurrenceRule"]).recurrenceRule, WEEKDAYS);
|
||||
});
|
||||
|
||||
it("reads useDefaultAlerts as false until it is set", () => {
|
||||
// It used to read true until set.
|
||||
assert.equal(eventGetView(series(), false, ["useDefaultAlerts"]).useDefaultAlerts, false);
|
||||
assert.equal(eventGetView({ ...series(), useDefaultAlerts: true }, false, ["useDefaultAlerts"]).useDefaultAlerts, true);
|
||||
assert.equal(eventGetView({ ...series(), useDefaultAlerts: false }, false, ["useDefaultAlerts"]).useDefaultAlerts, false);
|
||||
});
|
||||
|
||||
it("returns only the id for an empty list", () => {
|
||||
// 0.16.21 treated an empty list as asking for everything.
|
||||
assert.deepEqual(eventGetView(series(), false, []), { id: "ev1" });
|
||||
});
|
||||
|
||||
it("returns the object unchanged when no list is given", () => {
|
||||
assert.deepEqual(eventGetView(series(), false, null), series());
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSyntheticId", () => {
|
||||
it("round-trips", () => {
|
||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.21.
|
||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.22.
|
||||
*
|
||||
* The mock used to hand a recurring event back once, as its stored self. Three
|
||||
* things that only a live server showed were therefore impossible to develop
|
||||
@@ -188,6 +188,43 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Series properties a synthetic id answers `null` for, when they are named. */
|
||||
const NULL_ON_OCCURRENCE = new Set(["recurrenceRule", "recurrenceOverrides"]);
|
||||
|
||||
/**
|
||||
* The object a `CalendarEvent/get` with a `properties` list returns, as 0.16.22
|
||||
* builds it. Omitted or null `properties` returns the stored object unchanged.
|
||||
*
|
||||
* Three of the named properties are no longer read off the object:
|
||||
*
|
||||
* - `baseEventId` is the master's id on a synthetic id and `null` on anything
|
||||
* else. Through 0.16.21 an event read by its stored id reported that id as
|
||||
* its own base. An expanded query still hands a one-off a synthetic id, so
|
||||
* one read that way still carries a base, and `baseEventId` is still no
|
||||
* evidence of a series;
|
||||
* - `recurrenceRule` and `recurrenceOverrides` come back as `null` on a
|
||||
* synthetic id rather than being left out;
|
||||
* - `useDefaultAlerts` is the reader's own preference, and `false` when they
|
||||
* never set one. It used to read `true` until set. The mock has one reader,
|
||||
* so a value stored on the event stands in for that reader's.
|
||||
*
|
||||
* An empty list returns `id` alone, where 0.16.21 treated it as asking for
|
||||
* everything. `ContactCard/get` changed the same way.
|
||||
*
|
||||
* Read from the 0.16.22 source (`calendar_event/get.rs`) and its tests.
|
||||
*/
|
||||
export function eventGetView(event: Obj, synthetic: boolean, properties: string[] | null | undefined): Obj {
|
||||
if (!properties) return event;
|
||||
const out: Obj = { id: event.id };
|
||||
for (const p of properties) {
|
||||
if (p === "baseEventId") out[p] = synthetic ? event.baseEventId : null;
|
||||
else if (p === "useDefaultAlerts") out[p] = event.useDefaultAlerts === true;
|
||||
else if (synthetic && NULL_ON_OCCURRENCE.has(p)) out[p] = null;
|
||||
else if (p in event) out[p] = event[p];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- what a single occurrence will not take ---------- */
|
||||
|
||||
/** Refused outright, with `invalidProperties`. */
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { interpretServerAccount, normalizePermission } from "./upstream.js";
|
||||
|
||||
/**
|
||||
* `/api/account` is the only place Stalwart lists what an account may do, and
|
||||
* ihasmail used to read the edition out of it and throw the rest away.
|
||||
*/
|
||||
test("the account's permissions are kept alongside the edition", () => {
|
||||
const info = interpretServerAccount({ edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"], locale: "en_US" });
|
||||
assert.deepEqual(info, { edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"] });
|
||||
});
|
||||
|
||||
test("permission names read the same whichever case the server uses", () => {
|
||||
// The source serialises camelCase; the documentation shows kebab-case.
|
||||
assert.equal(normalizePermission("sys-account-get"), "sysAccountGet");
|
||||
assert.equal(normalizePermission("sysAccountGet"), "sysAccountGet");
|
||||
assert.equal(normalizePermission("sys-dkim-signature-create"), "sysDkimSignatureCreate");
|
||||
assert.deepEqual(interpretServerAccount({ permissions: ["sys-account-get", "sysAccountGet"] }).permissions, ["sysAccountGet"]);
|
||||
});
|
||||
|
||||
test("a body without a usable list yields no permissions rather than failing", () => {
|
||||
assert.deepEqual(interpretServerAccount({ edition: "oss" }), { edition: "oss", permissions: [] });
|
||||
assert.deepEqual(interpretServerAccount({ permissions: "sysAccountGet" }), { edition: null, permissions: [] });
|
||||
assert.deepEqual(interpretServerAccount({ permissions: [1, null, "sysDomainGet"] }).permissions, ["sysDomainGet"]);
|
||||
assert.deepEqual(interpretServerAccount(null), { edition: null, permissions: [] });
|
||||
});
|
||||
+44
-11
@@ -132,11 +132,21 @@ export interface AccountInfo {
|
||||
locale: string | null;
|
||||
/** "oss" | "community" | "enterprise", where the server reports it. */
|
||||
edition: string | null;
|
||||
/**
|
||||
* The account's effective permissions, as Stalwart reports them for the
|
||||
* credential in use. Empty when the server would not say.
|
||||
*
|
||||
* Carried to the browser so it can offer only what the account may do --
|
||||
* administration above all. It is never a grant: Stalwart checks every call
|
||||
* it is sent, and a list that is stale or wrong costs a refused request, not
|
||||
* access.
|
||||
*/
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
const INFO_CACHE_MS = 30 * 60_000;
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
||||
|
||||
/**
|
||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||
@@ -198,7 +208,9 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
Object.keys(session.accounts ?? {})[0];
|
||||
if (!accountId) return EMPTY_INFO;
|
||||
const res = await fetch(absoluteUpstream(session.apiUrl), {
|
||||
// Against the server that issued this session, not the default: with a
|
||||
// domain mapped elsewhere, the default has never heard of the account.
|
||||
const res = await fetch(absoluteUpstream(session.apiUrl, session.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -226,7 +238,7 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
||||
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
||||
const settings = responses.find((r) => r[2] === "s");
|
||||
const account = responses.find((r) => r[2] === "a");
|
||||
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
|
||||
return { locale: localeOf(settings) ?? localeOf(account), edition: null, permissions: [] };
|
||||
}
|
||||
|
||||
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
||||
@@ -237,30 +249,51 @@ function localeOf(call: [string, Record<string, unknown>, string] | undefined):
|
||||
}
|
||||
|
||||
/**
|
||||
* Which edition the server is running. Stalwart deliberately does not publish
|
||||
* its version number to clients, but 0.16 does report its edition here.
|
||||
* Permission names in the form the source serialises them.
|
||||
*
|
||||
* Stalwart 0.16 builds `/api/account`'s list from the same enum as everything
|
||||
* else, which serialises as camelCase (`sysAccountGet`). Its documentation and
|
||||
* OpenAPI example show kebab-case (`sys-account-get`) instead. Until a live
|
||||
* server settles which is true, both are read as the one form, so a check
|
||||
* written against `sysAccountGet` holds either way.
|
||||
*/
|
||||
async function fetchEdition(authorization: string, base: string): Promise<string | null> {
|
||||
export function normalizePermission(name: string): string {
|
||||
return name.includes("-") ? name.replace(/-([a-z0-9])/g, (_m, c: string) => c.toUpperCase()) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server says about the signed-in account: its edition and its
|
||||
* effective permissions. Stalwart deliberately does not publish its version
|
||||
* number to clients, but 0.16 reports both of these here.
|
||||
*/
|
||||
async function fetchServerAccount(authorization: string, base: string): Promise<Pick<AccountInfo, "edition" | "permissions">> {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/account`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { edition?: unknown };
|
||||
return typeof body.edition === "string" ? body.edition : null;
|
||||
if (!res.ok) return { edition: null, permissions: [] };
|
||||
return interpretServerAccount(await res.json());
|
||||
} catch {
|
||||
return null;
|
||||
return { edition: null, permissions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function interpretServerAccount(body: unknown): Pick<AccountInfo, "edition" | "permissions"> {
|
||||
const b = (body ?? {}) as { edition?: unknown; permissions?: unknown };
|
||||
const permissions = Array.isArray(b.permissions)
|
||||
? [...new Set(b.permissions.filter((p): p is string => typeof p === "string").map(normalizePermission))]
|
||||
: [];
|
||||
return { edition: typeof b.edition === "string" ? b.edition : null, permissions };
|
||||
}
|
||||
|
||||
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||
const cached = infoCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
|
||||
let info = EMPTY_INFO;
|
||||
try {
|
||||
info = await fetchAccountInfo(authorization, session);
|
||||
info = { ...info, edition: await fetchEdition(authorization, session.baseUrl) };
|
||||
info = { ...info, ...(await fetchServerAccount(authorization, session.baseUrl)) };
|
||||
} catch {
|
||||
/* all of this is a nicety - never fail the session over it */
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m)
|
||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||
const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView })));
|
||||
const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView })));
|
||||
// Only ever opened by the few who administer, so nobody else downloads it.
|
||||
const AdminView = lazy(() => import("@/views/admin/AdminView").then((m) => ({ default: m.AdminView })));
|
||||
|
||||
export function App() {
|
||||
const status = useSession((s) => s.status);
|
||||
@@ -305,6 +307,7 @@ function AuthedApp() {
|
||||
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
|
||||
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
|
||||
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
|
||||
<Route path="/admin/:section?/:id?">{(p) => <AdminView section={p.section} id={p.id} />}</Route>
|
||||
<Route path="/login">
|
||||
<Redirect to="/mail" />
|
||||
</Route>
|
||||
|
||||
@@ -19,6 +19,9 @@ export const CAP = {
|
||||
websocket: "urn:ietf:params:jmap:websocket",
|
||||
} as const;
|
||||
|
||||
/** Stalwart's own capability, which carries its `x:` registry methods. */
|
||||
export const STALWART_CAP = "urn:stalwart:jmap";
|
||||
|
||||
export class JmapMethodError extends Error {
|
||||
constructor(
|
||||
public readonly method: string,
|
||||
@@ -349,6 +352,9 @@ export class JmapClient {
|
||||
/** Map method name prefix → required capability URNs. */
|
||||
function usingFor(method: string): string[] {
|
||||
const type = method.split("/")[0] ?? "";
|
||||
// Stalwart's registry: accounts, domains, credentials. Advertised per
|
||||
// account rather than in the session, which supportedUsing() allows for.
|
||||
if (type.startsWith("x:")) return [STALWART_CAP];
|
||||
switch (type) {
|
||||
case "Mailbox":
|
||||
case "Thread":
|
||||
|
||||
@@ -39,6 +39,19 @@ export interface JmapSession {
|
||||
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
||||
edition?: string | null;
|
||||
};
|
||||
/**
|
||||
* False when this session may not administer: the operator turned it off,
|
||||
* or the session was signed in without "This is my own device".
|
||||
*/
|
||||
administration?: boolean;
|
||||
/** An administrator on a device not marked as their own; the menu says so. */
|
||||
administrationNeedsOwnDevice?: boolean;
|
||||
/**
|
||||
* The account's effective permissions on that server, as Stalwart reports
|
||||
* them. What the client offers is shaped by these; what is allowed is
|
||||
* decided by Stalwart on every call.
|
||||
*/
|
||||
permissions?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADMIN_BASELINE, adminSections, can, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
|
||||
const set = (...p: string[]) => permissionSet(p);
|
||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||
const helpdesk = set("sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "jmapEmailGet");
|
||||
const roles = new Map<string, RoleDef>([
|
||||
["user", { id: "user", enabledPermissions: { jmapEmailGet: true } }],
|
||||
["helpdesk", { id: "helpdesk", enabledPermissions: { sysAccountGet: true, sysAccountQuery: true, sysAccountUpdate: true }, roleIds: { user: true } }],
|
||||
["dns", { id: "dns", enabledPermissions: { sysDnsServerUpdate: true }, roleIds: { user: true } }],
|
||||
["loop", { id: "loop", enabledPermissions: {}, roleIds: { loop: true } }],
|
||||
]);
|
||||
|
||||
describe("who is offered administration", () => {
|
||||
it("needs both halves of reading the account list", () => {
|
||||
expect(hasAdministration(set("sysAccountQuery", "sysAccountGet"))).toBe(true);
|
||||
expect(hasAdministration(set("sysAccountQuery"))).toBe(false);
|
||||
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
|
||||
expect(hasAdministration(permissionSet(undefined))).toBe(false);
|
||||
});
|
||||
|
||||
it("offers each section only with both halves of reading it", () => {
|
||||
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["domains"]);
|
||||
expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true);
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["accounts"]);
|
||||
});
|
||||
|
||||
it("reads one permission per object and operation", () => {
|
||||
expect(can(helpdesk, "Account", "Update")).toBe(true);
|
||||
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
|
||||
expect(can(helpdesk, "Domain", "Get")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart checks a grant, but not a password change or a delete. Without this,
|
||||
* anyone allowed to edit accounts could take over one that can do more.
|
||||
*/
|
||||
describe("an account that outranks the viewer", () => {
|
||||
it("an ordinary user never does", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "User" } }, null)).toBe(false);
|
||||
expect(outranks(helpdesk, {}, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("an administrator does, unless the viewer is one too", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Admin" } }, roles)).toBe(true);
|
||||
expect(outranks(everything, { roles: { "@type": "Admin" } }, roles)).toBe(false);
|
||||
});
|
||||
|
||||
it("a custom role does when it carries something the viewer lacks", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, roles)).toBe(false);
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } } }, roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("a role that cannot be read counts against the target, not for it", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, null)).toBe(true);
|
||||
expect(outranks(everything, { roles: { "@type": "Custom", roleIds: { gone: true } } }, roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("extra permissions on the account itself are counted", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "User" }, permissions: { "@type": "Merge", enabledPermissions: { sysDomainDestroy: true } } }, roles)).toBe(true);
|
||||
// Replace ignores the roles entirely, so only what it lists matters.
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } }, permissions: { "@type": "Replace", enabledPermissions: { jmapEmailGet: true } } }, roles)).toBe(false);
|
||||
});
|
||||
|
||||
it("survives a role that names itself", () => {
|
||||
expect(resolveRoles(["loop"], roles)).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("granting a role", () => {
|
||||
it("is offered only for roles whose every permission the viewer holds", () => {
|
||||
expect(canGrantRole(helpdesk, "helpdesk", roles)).toBe(true);
|
||||
expect(canGrantRole(helpdesk, "dns", roles)).toBe(false);
|
||||
expect(canGrantRole(everything, "missing", roles)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated passwords", () => {
|
||||
it("are four groups of five unambiguous characters", () => {
|
||||
const p = generatePassword();
|
||||
expect(p).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
|
||||
expect(p).not.toMatch(/[01lIO]/);
|
||||
});
|
||||
|
||||
it("skip bytes that would favour the start of the alphabet", () => {
|
||||
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
|
||||
// the first 36 characters twice as often. Bytes of 220 and up are dropped
|
||||
// and more are drawn, so a batch of nothing but those costs a draw.
|
||||
let call = 0;
|
||||
const source = (n: number) => (call++ === 0 ? new Uint8Array(n).fill(250) : Uint8Array.from({ length: n }, (_, i) => i));
|
||||
expect(generatePassword(source)).toBe("abcde-fghjk-mnpqr-stuvw");
|
||||
expect(call).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/adminDirectory";
|
||||
|
||||
describe("setting a password", () => {
|
||||
it("writes into the existing password credential, keeping its place", () => {
|
||||
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "2": { "@type": "Password" as const, secret: "[********]" } } };
|
||||
expect(passwordPatch(account, "new secret")).toEqual({ "credentials/2/secret": "new secret" });
|
||||
});
|
||||
|
||||
it("adds one after the last index when the account has none", () => {
|
||||
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "3": { "@type": "ApiKey" as const } } };
|
||||
expect(passwordPatch(account, "s")).toEqual({ "credentials/4": { "@type": "Password", secret: "s" } });
|
||||
expect(passwordPatch({}, "s")).toEqual({ "credentials/0": { "@type": "Password", secret: "s" } });
|
||||
expect(hasPassword(account)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lists written back", () => {
|
||||
it("re-index aliases the way the server stores a list", () => {
|
||||
expect(aliasList([{ name: "b", domainId: "d1" }, { name: "c", domainId: "d2", enabled: false }])).toEqual({
|
||||
"0": { enabled: true, name: "b", domainId: "d1", description: null },
|
||||
"1": { enabled: false, name: "c", domainId: "d2", description: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("change the disk limit without touching the other quotas", () => {
|
||||
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, 7)).toEqual({ maxEmails: 10, maxDiskQuota: 7 });
|
||||
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, null)).toEqual({ maxEmails: 10 });
|
||||
expect(quotasWithDisk(undefined, 0)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("explaining a refusal", () => {
|
||||
it("says what a taken address means", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", "exists"))).toMatch(/already in use/);
|
||||
});
|
||||
|
||||
it("keeps the server's own words for a password policy", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Password must be at least 8 characters long.", ["secret"]))).toContain("at least 8 characters");
|
||||
});
|
||||
|
||||
it("handles a method-level refusal as well as a set error", () => {
|
||||
expect(describeDirectoryError({ type: "forbidden", message: "x:Account/set: forbidden" })).toMatch(/refused/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the account query", () => {
|
||||
it("filters on @type, the property's name on the object", async () => {
|
||||
// A live 0.16 server answers a plain `type` with "unsupportedFilter - type"
|
||||
// and fails the whole list, which is how this was found.
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
|
||||
await queryAccounts({ type: "User", text: " ada ", position: 50, limit: 50 });
|
||||
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", text: "ada" }, position: 50, limit: 50, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart explains a refusal in English, and none of it should reach an
|
||||
* interface in another language as it is. Each case below is a refusal a
|
||||
* live server gave, or one its source says it gives.
|
||||
*/
|
||||
describe("refusals in the reader's language", () => {
|
||||
it("recognises the registry's validators and says it again, without the server's words", () => {
|
||||
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
|
||||
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
|
||||
expect(domain).toMatch(/isn't a valid domain name/);
|
||||
expect(domain).not.toContain("Invalid domain name");
|
||||
expect(describeDirectoryError(new DirectoryError("invalidPatch", "Invalid email address", ["catchAllAddress"]), "domain")).toMatch(/full address/);
|
||||
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Invalid email local part", ["name"]))).toMatch(/before the @/);
|
||||
});
|
||||
|
||||
it("never echoes a description it does not know", () => {
|
||||
const text = describeDirectoryError(new DirectoryError("invalidPatch", "Something only the server would say", ["whatever"]));
|
||||
expect(text).not.toContain("Something only the server would say");
|
||||
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not allowed to do that thing"))).not.toContain("not allowed to do that thing");
|
||||
expect(describeDirectoryError(new DirectoryError("someNewType", "Brand new English"))).not.toContain("Brand new English");
|
||||
});
|
||||
|
||||
it("tells a grant refusal and a directory-backed account apart from a plain no", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not authorized to grant permissions: sysDomainDestroy."))).toMatch(/permissions your own role/);
|
||||
expect(describeDirectoryError(new DirectoryError("forbidden", "Cannot set credentials for accounts in an external directory."))).toMatch(/external directory/);
|
||||
});
|
||||
|
||||
it("words a clash and a missing object for what it was about", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined, ["name"]), "domain")).toMatch(/domain name is already in use/);
|
||||
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined))).toMatch(/address is already in use/);
|
||||
expect(describeDirectoryError(new DirectoryError("notFound", undefined), "domain")).toMatch(/domain no longer exists/);
|
||||
});
|
||||
|
||||
it("explains ihasmail's own refusals by their code, not their English message", () => {
|
||||
const own = { status: 403, code: "administration_needs_own_device", message: "Administration is only available when signed in on a device marked as your own (x:Account/query)." };
|
||||
expect(describeDirectoryError(own)).toMatch(/marked as your own/);
|
||||
expect(describeDirectoryError(own)).not.toContain("x:Account/query");
|
||||
expect(describeDirectoryError({ status: 403, code: "administration_disabled", message: "…" })).toMatch(/turned off/);
|
||||
expect(describeDirectoryError({ method: "x:Account/query", type: "unsupportedFilter", message: "x:Account/query: unsupportedFilter - type" })).toBe("The mail server could not carry out the request (unsupportedFilter).");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeLinked, dkimAlgorithm, looksLikeDomain, normaliseDomain, parseZoneFile } from "@/lib/adminDomains";
|
||||
|
||||
/**
|
||||
* Written the way Stalwart's BIND serialiser writes it (dns-update's
|
||||
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
|
||||
* parenthesised run of quoted chunks.
|
||||
*/
|
||||
const long = "v=DKIM1; k=rsa; h=sha256; p=" + "A".repeat(400);
|
||||
const zone = [
|
||||
"example.com. IN MX 10 mail.example.com.",
|
||||
'example.com. IN TXT "v=spf1 mx ra=postmaster -all"',
|
||||
"v1-rsa-20260601._domainkey.example.com. IN TXT (",
|
||||
...(long.match(/.{1,255}/g) ?? []).map((c) => ` "${c}"`),
|
||||
")",
|
||||
'_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:\\"postmaster\\"@example.com"',
|
||||
"_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.",
|
||||
'example.com. IN CAA 0 issue "letsencrypt.org"',
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("reading the zone file", () => {
|
||||
const records = parseZoneFile(zone);
|
||||
|
||||
it("gives one row per record, without the root dot", () => {
|
||||
expect(records.map((r) => r.type)).toEqual(["MX", "TXT", "TXT", "TXT", "SRV", "CAA"]);
|
||||
expect(records[0]).toMatchObject({ name: "example.com", value: "10 mail.example.com." });
|
||||
});
|
||||
|
||||
it("joins a split TXT record back into the value a DNS form wants", () => {
|
||||
expect(records[2]!.name).toBe("v1-rsa-20260601._domainkey.example.com");
|
||||
expect(records[2]!.value).toBe(long);
|
||||
expect(records[2]!.line).toContain("(");
|
||||
});
|
||||
|
||||
it("unquotes and unescapes TXT values, and leaves other types as written", () => {
|
||||
expect(records[1]!.value).toBe("v=spf1 mx ra=postmaster -all");
|
||||
expect(records[3]!.value).toBe('v=DMARC1; p=reject; rua=mailto:"postmaster"@example.com');
|
||||
expect(records[5]!.value).toBe('0 issue "letsencrypt.org"');
|
||||
});
|
||||
|
||||
it("keeps a line it cannot read rather than dropping it", () => {
|
||||
expect(parseZoneFile("something unexpected")).toEqual([{ name: "", type: "", value: "something unexpected", line: "something unexpected" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("domain names", () => {
|
||||
it("are written back lower-case without the root dot", () => {
|
||||
expect(normaliseDomain(" Example.COM. ")).toBe("example.com");
|
||||
});
|
||||
|
||||
it("are checked loosely before the server decides", () => {
|
||||
expect(looksLikeDomain("mail.example.co.uk")).toBe(true);
|
||||
expect(looksLikeDomain("example")).toBe(false);
|
||||
expect(looksLikeDomain("exa mple.com")).toBe(false);
|
||||
expect(looksLikeDomain("-bad.example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("explaining what still uses a domain", () => {
|
||||
it("counts by kind", () => {
|
||||
expect(describeLinked(["Account", "Account", "DkimSignature", "MailingList", "Whatever"])).toBe("2 accounts, 1 DKIM key, 1 mailing list, 1 other item");
|
||||
});
|
||||
|
||||
it("names a key's algorithm from its type", () => {
|
||||
expect(dkimAlgorithm("Dkim1Ed25519Sha256")).toBe("Ed25519 · DKIM1");
|
||||
expect(dkimAlgorithm("Dkim2RsaSha256")).toBe("RSA · DKIM2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* What the signed-in account may administer, read from the permissions Stalwart
|
||||
* reported for it at sign-in.
|
||||
*
|
||||
* None of this is a security boundary, and nothing here should read as one.
|
||||
* Every administrative call is a JMAP `x:` method sent through the ordinary
|
||||
* proxy, and Stalwart checks each of them against the credential making it --
|
||||
* scoping a tenant administrator's queries to their own tenant, and refusing a
|
||||
* write the account may not make. What this decides is only what the client
|
||||
* *offers*: a menu that appears for the people it can do something for, and
|
||||
* buttons that are there when pressing them would work.
|
||||
*
|
||||
* The one place it is more than presentation is `outranks`, which stands in
|
||||
* for a check Stalwart does not make. See there.
|
||||
*/
|
||||
|
||||
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant";
|
||||
export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy";
|
||||
|
||||
export type Permissions = ReadonlySet<string>;
|
||||
|
||||
export function permissionSet(list: readonly string[] | null | undefined): Permissions {
|
||||
return new Set(list ?? []);
|
||||
}
|
||||
|
||||
export function can(perms: Permissions, object: AdminObject, op: AdminOp): boolean {
|
||||
return perms.has(`sys${object}${op}`);
|
||||
}
|
||||
|
||||
export type AdminSection = "accounts" | "domains";
|
||||
|
||||
/**
|
||||
* The sections an account may open, in the order they are listed.
|
||||
*
|
||||
* A list that cannot be read is not worth an entry, so each takes both halves
|
||||
* of reading one: the query that finds the objects and the get that shows them.
|
||||
*/
|
||||
export function adminSections(perms: Permissions): AdminSection[] {
|
||||
const out: AdminSection[] = [];
|
||||
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts");
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether to offer Administration at all: when there is a section to open. */
|
||||
export function hasAdministration(perms: Permissions): boolean {
|
||||
return adminSections(perms).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an administrator holds, at the least: Stalwart's built-in Tenant
|
||||
* Administrator role, for the parts of it that manage people and domains.
|
||||
* Anyone who has all of this can already do anything to the accounts an
|
||||
* "Administrator" account could.
|
||||
*/
|
||||
export const ADMIN_BASELINE: readonly string[] = (["Account", "Domain", "Role", "MailingList"] as const).flatMap((o) =>
|
||||
(["Get", "Query", "Create", "Update", "Destroy"] as const).map((op) => `sys${o}${op}`),
|
||||
);
|
||||
|
||||
export type UserRoles = { "@type": "User" } | { "@type": "Admin" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
|
||||
|
||||
export type PermissionsMode =
|
||||
| { "@type": "Inherit" }
|
||||
| { "@type": "Merge" | "Replace"; enabledPermissions?: Record<string, boolean>; disabledPermissions?: Record<string, boolean> };
|
||||
|
||||
export interface RoleDef {
|
||||
id: string;
|
||||
description?: string | null;
|
||||
enabledPermissions?: Record<string, boolean>;
|
||||
roleIds?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account can do something the viewer cannot.
|
||||
*
|
||||
* Stalwart checks that a caller holds every permission they grant -- when
|
||||
* roles or permissions change, and when an account is created. It does not
|
||||
* check when only a password changes, and it does not check a delete. So an
|
||||
* account allowed to edit accounts could reset the password of one with far
|
||||
* more rights than its own and sign in as it. ihasmail refuses to offer that,
|
||||
* and treats such an account as read-only.
|
||||
*
|
||||
* It errs towards refusing. A role that cannot be read -- the viewer lacks
|
||||
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
|
||||
* an unknown grant is not a grant the viewer can be shown to hold. What it
|
||||
* cannot see is tenancy: an "Administrator" account is a tenant administrator
|
||||
* inside a tenant and a server administrator outside one, and a tenant-scoped
|
||||
* viewer is not told which it is looking at. It never sees the second kind,
|
||||
* which is why comparing against the administrator baseline is enough there.
|
||||
*/
|
||||
export function outranks(
|
||||
viewer: Permissions,
|
||||
target: { roles?: UserRoles | null; permissions?: PermissionsMode | null },
|
||||
roles: ReadonlyMap<string, RoleDef> | null,
|
||||
): boolean {
|
||||
let granted = new Set<string>();
|
||||
const kind = target.roles?.["@type"] ?? "User";
|
||||
if (kind === "Admin") {
|
||||
if (!ADMIN_BASELINE.every((p) => viewer.has(p))) return true;
|
||||
} else if (kind === "Custom") {
|
||||
const ids = Object.keys((target.roles as { roleIds?: Record<string, boolean> }).roleIds ?? {});
|
||||
const resolved = resolveRoles(ids, roles);
|
||||
if (!resolved) return true;
|
||||
granted = resolved;
|
||||
}
|
||||
const mode = target.permissions;
|
||||
if (mode && mode["@type"] !== "Inherit") {
|
||||
const enabled = Object.keys(mode.enabledPermissions ?? {});
|
||||
granted = mode["@type"] === "Replace" ? new Set(enabled) : new Set([...granted, ...enabled]);
|
||||
}
|
||||
for (const p of granted) if (!viewer.has(p)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Every permission a set of roles grants, nested roles included; null if any cannot be read. */
|
||||
export function resolveRoles(ids: readonly string[], roles: ReadonlyMap<string, RoleDef> | null): Set<string> | null {
|
||||
if (!ids.length) return new Set();
|
||||
if (!roles) return null;
|
||||
const out = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
const walk = (id: string): boolean => {
|
||||
if (seen.has(id)) return true;
|
||||
seen.add(id);
|
||||
const role = roles.get(id);
|
||||
if (!role) return false;
|
||||
for (const p of Object.keys(role.enabledPermissions ?? {})) out.add(p);
|
||||
return Object.keys(role.roleIds ?? {}).every(walk);
|
||||
};
|
||||
return ids.every(walk) ? out : null;
|
||||
}
|
||||
|
||||
/** Whether the viewer could grant a role: they hold everything it carries. */
|
||||
export function canGrantRole(viewer: Permissions, roleId: string, roles: ReadonlyMap<string, RoleDef> | null): boolean {
|
||||
const granted = resolveRoles([roleId], roles);
|
||||
return granted !== null && [...granted].every((p) => viewer.has(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* A password to hand to somebody who will change it.
|
||||
*
|
||||
* Twenty characters from an alphabet without the ones people misread aloud
|
||||
* (0/O, 1/l/I), in groups of five. Rejection sampling, so every character is
|
||||
* equally likely rather than the first few of the alphabet slightly more.
|
||||
*/
|
||||
const ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
export function generatePassword(random: (n: number) => Uint8Array = (n) => crypto.getRandomValues(new Uint8Array(n))): string {
|
||||
const out: string[] = [];
|
||||
const limit = 256 - (256 % ALPHABET.length);
|
||||
while (out.length < 20) {
|
||||
for (const byte of random(32)) {
|
||||
if (byte < limit && out.length < 20) out.push(ALPHABET[byte % ALPHABET.length]!);
|
||||
}
|
||||
}
|
||||
return [0, 5, 10, 15].map((i) => out.slice(i, i + 5).join("")).join("-");
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/adminAccess";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
|
||||
*
|
||||
* 0.16 removed the REST management API (`/api/principal` and the rest); people,
|
||||
* domains and roles are registry objects now, read and written with `x:Account`,
|
||||
* `x:Domain` and `x:Role`. These go through `/api/jmap` like every other call,
|
||||
* authenticated as the signed-in account, so ihasmail holds nothing new: no
|
||||
* route of its own, no store, no cache beyond the component showing the list.
|
||||
*
|
||||
* Shapes, from the 0.16.22 source:
|
||||
*
|
||||
* - A list (credentials, aliases) is an object keyed by index, `{"0": …}`. A
|
||||
* set (memberGroupIds, role ids, permissions) is `{"id": true}`.
|
||||
* - An account's `name` is its local part, and its domain is a `domainId`.
|
||||
* `emailAddress` and `usedDiskQuota` are computed by the server.
|
||||
* - Secrets read back masked. A new password is written to the existing
|
||||
* password credential, so its id -- which OAuth tokens are tied to -- stays.
|
||||
* - Filters are AND only, keyed by property name as it appears on the object
|
||||
* (`@type`, not `type`), and the default order is newest first.
|
||||
*
|
||||
* Query and get are two requests rather than one with a result reference.
|
||||
* Whether the registry methods resolve back-references has not been checked on
|
||||
* a live server, and a list that loads a moment slower is a better failure than
|
||||
* one that never loads.
|
||||
*/
|
||||
|
||||
export interface EmailAlias {
|
||||
enabled?: boolean;
|
||||
name: string;
|
||||
domainId: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface Credential {
|
||||
"@type": "Password" | "AppPassword" | "ApiKey";
|
||||
secret?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DirectoryAccount {
|
||||
id: string;
|
||||
"@type": "User" | "Group";
|
||||
name: string;
|
||||
domainId: string;
|
||||
emailAddress?: string;
|
||||
description?: string | null;
|
||||
roles?: UserRoles;
|
||||
permissions?: PermissionsMode;
|
||||
quotas?: Record<string, number>;
|
||||
usedDiskQuota?: number;
|
||||
aliases?: Record<string, EmailAlias>;
|
||||
memberGroupIds?: Record<string, boolean>;
|
||||
credentials?: Record<string, Credential>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface DirectoryDomain {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const ACCOUNT_PROPERTIES = [
|
||||
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
|
||||
"usedDiskQuota", "aliases", "memberGroupIds", "credentials", "createdAt",
|
||||
];
|
||||
|
||||
/** The one quota ihasmail edits; the others keep whatever they had. */
|
||||
export const DISK_QUOTA = "maxDiskQuota";
|
||||
|
||||
/** An error with a SetError behind it, kept so the caller can explain it. */
|
||||
export class DirectoryError extends Error {
|
||||
constructor(
|
||||
readonly type: string,
|
||||
readonly description: string | undefined,
|
||||
readonly properties: string[] = [],
|
||||
) {
|
||||
super(description ?? type);
|
||||
this.name = "DirectoryError";
|
||||
}
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
ids: string[];
|
||||
total?: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export async function queryAccounts(opts: { type: "User" | "Group"; text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||
// The registry names the discriminator `@type`, as it is on the object. A
|
||||
// plain `type` is not a property it knows and fails the whole query.
|
||||
const filter: Record<string, unknown> = { "@type": opts.type };
|
||||
if (opts.text?.trim()) filter.text = opts.text.trim();
|
||||
const res = await client.call<QueryResult>("x:Account/query", {
|
||||
filter,
|
||||
position: opts.position ?? 0,
|
||||
...(opts.limit ? { limit: opts.limit } : {}),
|
||||
calculateTotal: true,
|
||||
});
|
||||
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||
}
|
||||
|
||||
export async function getAccounts(ids: string[]): Promise<DirectoryAccount[]> {
|
||||
if (!ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids, properties: ACCOUNT_PROPERTIES });
|
||||
// In the order the query gave, which is the order the list is shown in.
|
||||
const byId = new Map(res.list.map((a) => [a.id, a]));
|
||||
return ids.map((id) => byId.get(id)).filter((a): a is DirectoryAccount => Boolean(a));
|
||||
}
|
||||
|
||||
/** Every one of a kind, for the pickers. Capped by what the server allows in a get. */
|
||||
async function all<T>(object: "Domain" | "Role", properties: string[]): Promise<T[]> {
|
||||
const q = await client.call<QueryResult>(`x:${object}/query`, { limit: client.maxObjectsInGet });
|
||||
if (!q.ids?.length) return [];
|
||||
const res = await client.call<{ list: T[] }>(`x:${object}/get`, { ids: q.ids, properties });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
export const listDomains = () => all<DirectoryDomain>("Domain", ["name"]);
|
||||
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
|
||||
|
||||
export async function listGroups(): Promise<DirectoryAccount[]> {
|
||||
const q = await queryAccounts({ type: "Group", limit: client.maxObjectsInGet });
|
||||
if (!q.ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids: q.ids, properties: ["name", "emailAddress", "description"] });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined>;
|
||||
|
||||
function throwIfRefused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const failure = Object.values(res[kind] ?? {})[0];
|
||||
if (failure) throw new DirectoryError(failure.type, failure.description, failure.properties);
|
||||
}
|
||||
|
||||
export interface NewAccount {
|
||||
name: string;
|
||||
domainId: string;
|
||||
description: string;
|
||||
password: string;
|
||||
roles: UserRoles;
|
||||
diskQuotaBytes: number | null;
|
||||
}
|
||||
|
||||
export async function createAccount(input: NewAccount): Promise<string> {
|
||||
const res = await client.call<SetResponse & { created?: Record<string, { id: string }> }>("x:Account/set", {
|
||||
create: {
|
||||
n: {
|
||||
"@type": "User",
|
||||
name: input.name.trim(),
|
||||
domainId: input.domainId,
|
||||
description: input.description.trim() || null,
|
||||
credentials: { "0": { "@type": "Password", secret: input.password } },
|
||||
roles: input.roles,
|
||||
permissions: { "@type": "Inherit" },
|
||||
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
|
||||
aliases: {},
|
||||
memberGroupIds: {},
|
||||
// Required on create. Turning it on is one-way and not offered here.
|
||||
encryptionAtRest: { "@type": "Disabled" },
|
||||
},
|
||||
},
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the account was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateAccount(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:Account/set", { update: { [id]: patch } });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
export async function destroyAccount(id: string): Promise<void> {
|
||||
const res = await client.call<SetResponse>("x:Account/set", { destroy: [id] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch that sets a new password.
|
||||
*
|
||||
* Into the existing password credential when there is one, which keeps its
|
||||
* credential id; as a new credential after the last index when there is not --
|
||||
* an account that has only ever signed in through a directory, say. An account
|
||||
* holds one password at most, so adding a second is never the answer.
|
||||
*/
|
||||
export function passwordPatch(account: Pick<DirectoryAccount, "credentials">, secret: string): Record<string, unknown> {
|
||||
const entries = Object.entries(account.credentials ?? {});
|
||||
const existing = entries.find(([, c]) => c["@type"] === "Password");
|
||||
if (existing) return { [`credentials/${existing[0]}/secret`]: secret };
|
||||
const next = entries.reduce((max, [k]) => Math.max(max, Number(k) + 1), 0);
|
||||
return { [`credentials/${next}`]: { "@type": "Password", secret } };
|
||||
}
|
||||
|
||||
export function hasPassword(account: Pick<DirectoryAccount, "credentials">): boolean {
|
||||
return Object.values(account.credentials ?? {}).some((c) => c["@type"] === "Password");
|
||||
}
|
||||
|
||||
/** Re-index a list of aliases the way the server stores them. */
|
||||
export function aliasList(aliases: EmailAlias[]): Record<string, EmailAlias> {
|
||||
return Object.fromEntries(aliases.map((a, i) => [String(i), { enabled: a.enabled ?? true, name: a.name, domainId: a.domainId, description: a.description ?? null }]));
|
||||
}
|
||||
|
||||
/** The quotas object with the disk limit set or cleared, and every other quota kept. */
|
||||
export function quotasWithDisk(quotas: Record<string, number> | undefined, bytes: number | null): Record<string, number> {
|
||||
const next = { ...(quotas ?? {}) };
|
||||
if (bytes && bytes > 0) next[DISK_QUOTA] = bytes;
|
||||
else delete next[DISK_QUOTA];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own wording for a value one of its validators refused, and
|
||||
* what to say instead. These come from the registry's string validators
|
||||
* (`crates/registry/src/types/string.rs`), which is the whole list: anything
|
||||
* else Stalwart says about a value is picked up by the fallback below.
|
||||
*/
|
||||
const VALIDATOR_MESSAGES: Record<string, () => string> = {
|
||||
"Invalid domain name": () => t("That isn't a valid domain name. Use a name such as example.com, on a real top-level domain."),
|
||||
"Invalid email address": () => t("That isn't a valid email address. Use a full address, such as [email protected]."),
|
||||
"Invalid email local part": () => t("That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @."),
|
||||
"Invalid hostname or IP address": () => t("That isn't a valid host name or IP address."),
|
||||
"String cannot be empty": () => t("A required value was left empty."),
|
||||
};
|
||||
|
||||
/** What kind of thing a refusal was about, where the wording has to differ. */
|
||||
export type DirectoryObject = "account" | "domain";
|
||||
|
||||
/**
|
||||
* Say what went wrong in terms of the person's own action, in their language.
|
||||
*
|
||||
* Stalwart explains a refusal in English, and its words are never shown as
|
||||
* they are: an interface in German that answers in English reads as broken
|
||||
* even when the English is exact. Every type the registry returns has its
|
||||
* own message, and a value a validator refused is recognised by the
|
||||
* validator's wording and said again here.
|
||||
*
|
||||
* One exception, on purpose. A password policy is the server's to set -- a
|
||||
* length, a strength -- and there is no way to know its rule in advance to
|
||||
* translate it, so its reason is kept after a translated sentence. Dropping it
|
||||
* would leave "not accepted" with no way to find out why.
|
||||
*/
|
||||
export function describeDirectoryError(err: unknown, object: DirectoryObject = "account"): string {
|
||||
if (!(err instanceof DirectoryError)) {
|
||||
const e = err as { type?: string; code?: string; status?: number };
|
||||
// ihasmail's own proxy, refusing for this session or this installation.
|
||||
if (e?.code === "administration_needs_own_device") return t("Only on a device you've marked as your own. Sign in again with “This is my own device” ticked.");
|
||||
if (e?.code === "administration_disabled") return t("Administration is turned off on this installation.");
|
||||
if (e?.code === "network_error" || e?.status === 0) return t("Network error. Please check your connection.");
|
||||
if (e?.code === "rate_limited" || e?.status === 429) return t("Too many attempts. Please wait a few minutes and try again.");
|
||||
// A method-level JMAP error: the whole call was refused.
|
||||
if (e?.type === "forbidden") return t("The mail server refused this. Your role may not allow it.");
|
||||
if (e?.type) return t("The mail server could not carry out the request ({code}).", { code: e.type });
|
||||
return t("The mail server could not carry out the request ({code}).", { code: e?.code ?? "error" });
|
||||
}
|
||||
const description = err.description ?? "";
|
||||
switch (err.type) {
|
||||
case "forbidden":
|
||||
if (/not authorized to grant/i.test(description)) return t("You can't give an account permissions your own role doesn't have.");
|
||||
if (/external directory/i.test(description)) return t("This account signs in through an external directory, so its password can't be set here.");
|
||||
if (/licen[cs]ed account limit/i.test(description)) return t("The server's licence allows no more accounts.");
|
||||
return t("The mail server refused this. Your role may not allow it.");
|
||||
case "primaryKeyViolation":
|
||||
return object === "domain"
|
||||
? t("That domain name is already in use on this server, as a domain or another domain's other name.")
|
||||
: t("That address is already in use on this server, as an account, a list or an alias.");
|
||||
case "invalidForeignKey":
|
||||
return t("One of the chosen domain, role or group can't be used for this account.");
|
||||
case "overQuota":
|
||||
return object === "domain" ? t("Your organisation has reached the number of domains it is allowed.") : t("Your organisation has reached the number of accounts it is allowed.");
|
||||
case "objectIsLinked":
|
||||
return t("Something still depends on this, so the server kept it.");
|
||||
case "notFound":
|
||||
return object === "domain" ? t("This domain no longer exists. Someone may have removed it.") : t("This account no longer exists. Someone may have deleted it.");
|
||||
case "rateLimit":
|
||||
return t("Too many attempts. Please wait a few minutes and try again.");
|
||||
case "tooLarge":
|
||||
return t("That is more than the mail server accepts in one change.");
|
||||
case "invalidPatch":
|
||||
case "invalidProperties":
|
||||
case "validationFailed": {
|
||||
if (err.properties.includes("secret")) {
|
||||
return description ? t("The password was not accepted: {reason}", { reason: description }) : t("The password was not accepted.");
|
||||
}
|
||||
const known = VALIDATOR_MESSAGES[description];
|
||||
if (known) return known();
|
||||
return t("The mail server rejected one of the values. Check what you entered and try again.");
|
||||
}
|
||||
default:
|
||||
return t("The mail server refused the change ({code}).", { code: err.type });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16's domains, over the same proxy as accounts.
|
||||
*
|
||||
* From the 0.16.22 source (`Domain`, `DkimSignature`, and the registry's get):
|
||||
*
|
||||
* - `aliases` are other names for the domain, a set: `{"example.net": true}`.
|
||||
* - `dkimManagement`, `dnsManagement` and `certificateManagement` are each
|
||||
* `{"@type": "Manual"}` or `{"@type": "Automatic", …}`. A new domain gets
|
||||
* automatic DKIM and manual DNS and certificates unless told otherwise.
|
||||
* - `dnsZoneFile` is computed on read: every record the server wants published
|
||||
* for the domain, as BIND lines.
|
||||
* - A DKIM key is created with its private key, which the server validates;
|
||||
* with automatic management it makes and rotates them itself.
|
||||
* - Deleting a domain anything still points at is refused with
|
||||
* `objectIsLinked` and the list of what does -- including the domain's own
|
||||
* DKIM keys, which is why removing one means removing those first.
|
||||
*/
|
||||
|
||||
export interface Managed {
|
||||
"@type": "Manual" | "Automatic";
|
||||
dnsServerId?: string;
|
||||
acmeProviderId?: string;
|
||||
}
|
||||
|
||||
export interface DirectoryDomainFull {
|
||||
id: string;
|
||||
name: string;
|
||||
aliases?: Record<string, boolean>;
|
||||
isEnabled?: boolean;
|
||||
createdAt?: string;
|
||||
description?: string | null;
|
||||
catchAllAddress?: string | null;
|
||||
subAddressing?: { "@type": "Enabled" | "Disabled" | "Custom" };
|
||||
dkimManagement?: Managed;
|
||||
dnsManagement?: Managed;
|
||||
certificateManagement?: Managed;
|
||||
memberTenantId?: string | null;
|
||||
directoryId?: string | null;
|
||||
dnsZoneFile?: string;
|
||||
}
|
||||
|
||||
export interface DkimKey {
|
||||
id: string;
|
||||
"@type": string;
|
||||
selector: string;
|
||||
stage?: "active" | "pending" | "retiring" | "retired";
|
||||
createdAt?: string;
|
||||
nextTransitionAt?: string | null;
|
||||
}
|
||||
|
||||
const DOMAIN_PROPERTIES = [
|
||||
"name", "aliases", "isEnabled", "createdAt", "description", "catchAllAddress", "subAddressing",
|
||||
"dkimManagement", "dnsManagement", "certificateManagement", "memberTenantId", "directoryId",
|
||||
];
|
||||
|
||||
type SetFailure = { type: string; description?: string; properties?: string[]; linkedObjects?: { object?: string; id?: string }[] };
|
||||
type SetResponse = Record<string, Record<string, SetFailure | null | { id: string }> | undefined>;
|
||||
|
||||
/** A refusal, with what the server said still depends on the object. */
|
||||
export class DomainError extends DirectoryError {
|
||||
constructor(failure: SetFailure) {
|
||||
super(failure.type, failure.description, failure.properties);
|
||||
this.linked = (failure.linkedObjects ?? []).map((o) => String(o.object ?? ""));
|
||||
}
|
||||
readonly linked: string[];
|
||||
}
|
||||
|
||||
function refused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const failure = Object.values(res[kind] ?? {})[0] as SetFailure | undefined;
|
||||
if (failure) throw new DomainError(failure);
|
||||
}
|
||||
|
||||
export async function queryDomains(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (opts.text?.trim()) filter.text = opts.text.trim().toLowerCase();
|
||||
const res = await client.call<{ ids: string[]; total?: number }>("x:Domain/query", {
|
||||
filter,
|
||||
position: opts.position ?? 0,
|
||||
...(opts.limit ? { limit: opts.limit } : {}),
|
||||
calculateTotal: true,
|
||||
});
|
||||
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||
}
|
||||
|
||||
export async function getDomains(ids: string[], opts: { zoneFile?: boolean } = {}): Promise<DirectoryDomainFull[]> {
|
||||
if (!ids.length) return [];
|
||||
const properties = opts.zoneFile ? [...DOMAIN_PROPERTIES, "dnsZoneFile"] : DOMAIN_PROPERTIES;
|
||||
const res = await client.call<{ list: DirectoryDomainFull[] }>("x:Domain/get", { ids, properties });
|
||||
const byId = new Map(res.list.map((d) => [d.id, d]));
|
||||
return ids.map((id) => byId.get(id)).filter((d): d is DirectoryDomainFull => Boolean(d));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many accounts live on each domain. One query per domain, batched into as
|
||||
* few requests as the server allows; a count that fails is left out rather
|
||||
* than shown as zero, which would read as "safe to delete".
|
||||
*/
|
||||
export async function countAccounts(domainIds: string[]): Promise<Map<string, number>> {
|
||||
const counts = new Map<string, number>();
|
||||
await Promise.all(
|
||||
domainIds.map((domainId) =>
|
||||
client
|
||||
.call<{ total?: number; ids?: string[] }>("x:Account/query", { filter: { domainId }, limit: 1, calculateTotal: true })
|
||||
.then((r) => { if (typeof r.total === "number") counts.set(domainId, r.total); })
|
||||
.catch(() => {}),
|
||||
),
|
||||
);
|
||||
return counts;
|
||||
}
|
||||
|
||||
export async function listDkimKeys(domainId: string): Promise<DkimKey[]> {
|
||||
const q = await client.call<{ ids: string[] }>("x:DkimSignature/query", { filter: { domainId } });
|
||||
if (!q.ids?.length) return [];
|
||||
const res = await client.call<{ list: DkimKey[] }>("x:DkimSignature/get", { ids: q.ids, properties: ["@type", "selector", "stage", "createdAt", "nextTransitionAt"] });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
export async function namesOf(object: "Tenant" | "DnsServer", ids: string[]): Promise<Map<string, string>> {
|
||||
if (!ids.length) return new Map();
|
||||
const property = object === "Tenant" ? "name" : "description";
|
||||
const res = await client.call<{ list: Array<{ id: string } & Record<string, unknown>> }>(`x:${object}/get`, { ids, properties: [property] });
|
||||
return new Map(res.list.map((o) => [o.id, String(o[property] ?? o.id)]));
|
||||
}
|
||||
|
||||
/** Lower-case, no surrounding space or root dot: how a domain is written back. */
|
||||
export function normaliseDomain(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
/** Enough of a check to catch a typo before the server does; the server decides. */
|
||||
export function looksLikeDomain(name: string): boolean {
|
||||
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(normaliseDomain(name));
|
||||
}
|
||||
|
||||
export async function createDomain(input: { name: string; description: string }): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:Domain/set", {
|
||||
create: { n: { name: normaliseDomain(input.name), description: input.description.trim() || null } },
|
||||
});
|
||||
refused(res, "notCreated");
|
||||
const id = (res.created?.n as { id?: string } | undefined)?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the domain was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateDomain(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:Domain/set", { update: { [id]: patch } });
|
||||
refused(res, "notUpdated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a domain, and its DKIM keys with it.
|
||||
*
|
||||
* The keys go first, in the same request, because the server will not remove a
|
||||
* domain its keys still name. Keys that belong to a domain being removed sign
|
||||
* nothing afterwards, so there is no case for keeping them.
|
||||
*/
|
||||
export async function destroyDomain(id: string, dkimKeyIds: string[]): Promise<void> {
|
||||
if (dkimKeyIds.length) {
|
||||
const keys = await client.call<SetResponse>("x:DkimSignature/set", { destroy: dkimKeyIds });
|
||||
refused(keys, "notDestroyed");
|
||||
}
|
||||
const res = await client.call<SetResponse>("x:Domain/set", { destroy: [id] });
|
||||
refused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
export interface DnsRecord {
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
/** The line as the zone file had it, for copying into a BIND zone. */
|
||||
line: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the zone file Stalwart computes for a domain.
|
||||
*
|
||||
* Its serialiser writes one record per line as `name IN TYPE value`, and a TXT
|
||||
* record longer than 255 bytes as a parenthesised run of quoted strings, one
|
||||
* per line. A DNS provider's form wants the whole value, so the strings are
|
||||
* joined and unescaped; the original lines are kept for anyone pasting into a
|
||||
* zone. Anything that does not parse is kept too, as its own row, rather than
|
||||
* silently dropped from a list somebody is copying from.
|
||||
*/
|
||||
export function parseZoneFile(text: string): DnsRecord[] {
|
||||
const out: DnsRecord[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]!;
|
||||
if (!line.trim() || line.trim().startsWith(";")) continue;
|
||||
if (line.includes("(") && !line.includes(")")) {
|
||||
while (i + 1 < lines.length && !lines[i]!.includes(")")) line += `\n${lines[++i]}`;
|
||||
}
|
||||
const m = /^(\S+)\s+(?:\d+\s+)?(?:IN\s+)?([A-Z]+)\s+([\s\S]*)$/.exec(line.trim());
|
||||
if (!m) {
|
||||
out.push({ name: "", type: "", value: line.trim(), line: line.trim() });
|
||||
continue;
|
||||
}
|
||||
const [, name, type, rest] = m;
|
||||
let value = rest!.trim();
|
||||
if (type === "TXT") {
|
||||
const parts = [...value.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((p) => p[1]!.replace(/\\(.)/g, "$1"));
|
||||
if (parts.length) value = parts.join("");
|
||||
}
|
||||
out.push({ name: name!.replace(/\.$/, ""), type: type!, value, line: line.trim() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A readable name for a DKIM key's algorithm, from its `@type`. */
|
||||
export function dkimAlgorithm(type: string): string {
|
||||
const version = /^Dkim2/.test(type) ? "DKIM2" : "DKIM1";
|
||||
const algo = /Ed25519/i.test(type) ? "Ed25519" : /Rsa/i.test(type) ? "RSA" : type;
|
||||
return `${algo} · ${version}`;
|
||||
}
|
||||
|
||||
/** What still points at an object, counted by kind, for a refusal message. */
|
||||
export function describeLinked(linked: string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const kind of linked) counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||
const parts: string[] = [];
|
||||
for (const [kind, n] of counts) {
|
||||
if (kind === "Account") parts.push(plural(n, { one: "{n} account", other: "{n} accounts" }));
|
||||
else if (kind === "MailingList") parts.push(plural(n, { one: "{n} mailing list", other: "{n} mailing lists" }));
|
||||
else if (kind === "DkimSignature") parts.push(plural(n, { one: "{n} DKIM key", other: "{n} DKIM keys" }));
|
||||
else parts.push(plural(n, { one: "{n} other item", other: "{n} other items" }));
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
+149
-10
@@ -55,6 +55,146 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"Domains": "Domains",
|
||||
"By hand": "Manuell",
|
||||
"Signing": "Signiert",
|
||||
"Published, not signing yet": "Veröffentlicht, signiert noch nicht",
|
||||
"Retiring": "Wird ausgemustert",
|
||||
"Retired": "Ausgemustert",
|
||||
"This domain no longer exists. Someone may have removed it.": "Diese Domain existiert nicht mehr. Möglicherweise hat sie jemand entfernt.",
|
||||
"That doesn't look like a domain name, such as example.com.": "Das sieht nicht nach einem Domainnamen wie example.com aus.",
|
||||
"Added {name}. Its DNS records are ready to copy.": "{name} hinzugefügt. Die DNS-Einträge können kopiert werden.",
|
||||
"Saved {name}": "{name} gespeichert",
|
||||
"Add domain": "Domain hinzufügen",
|
||||
"Added {date}": "Hinzugefügt am {date}",
|
||||
"This domain is disabled on the server.": "Diese Domain ist auf dem Server deaktiviert.",
|
||||
"Your role lets you view domains but not change them.": "Ihre Rolle erlaubt es, Domains anzusehen, aber nicht zu ändern.",
|
||||
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Neue Domains signieren ihre E-Mails mit DKIM-Schlüsseln, die der Server erstellt und wechselt. Die DNS-Einträge erscheinen hier, sobald die Domain hinzugefügt ist.",
|
||||
"Other names": "Weitere Namen",
|
||||
"Delivery": "Zustellung",
|
||||
"Catch-all address": "Sammeladresse",
|
||||
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "E-Mails an eine Adresse, die auf dieser Domain niemand hat, werden hierher zugestellt. Leer lassen, um sie abzulehnen.",
|
||||
"Plus addressing": "Plus-Adressierung",
|
||||
"Set by a custom rule on the server.": "Durch eine eigene Regel auf dem Server festgelegt.",
|
||||
"Mail to name+anything@ is delivered to name@.": "E-Mails an name+beliebig@ werden an name@ zugestellt.",
|
||||
"DNS records": "DNS-Einträge",
|
||||
"Published automatically through {provider}.": "Automatisch über {provider} veröffentlicht.",
|
||||
"Published automatically by the server.": "Automatisch vom Server veröffentlicht.",
|
||||
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Tragen Sie diese dort ein, wo das DNS dieser Domain verwaltet wird. Bis dahin werden E-Mails weder zugestellt noch als vertrauenswürdig eingestuft.",
|
||||
"Copy {type} record for {name}": "{type}-Eintrag für {name} kopieren",
|
||||
"Copy value": "Wert kopieren",
|
||||
"Copied the zone file": "Zonendatei kopiert",
|
||||
"Copy all as a zone file": "Alles als Zonendatei kopieren",
|
||||
"The server returned no records for this domain.": "Der Server hat für diese Domain keine Einträge geliefert.",
|
||||
"DKIM keys": "DKIM-Schlüssel",
|
||||
"The server creates and rotates these keys itself.": "Der Server erstellt und wechselt diese Schlüssel selbst.",
|
||||
"These keys are managed by hand on the server.": "Diese Schlüssel werden auf dem Server manuell verwaltet.",
|
||||
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Keine DKIM-Schlüssel: E-Mails von dieser Domain werden nicht signiert und landen eher im Spam.",
|
||||
"Managed by the server": "Vom Server verwaltet",
|
||||
"Certificate": "Zertifikat",
|
||||
"Another name for this domain": "Weiterer Name für diese Domain",
|
||||
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "E-Mails an dieselbe Adresse unter einem dieser Namen erreichen dasselbe Konto. Änderungen gelten nach dem Speichern.",
|
||||
"Its DKIM keys have to be removed first, and your role can't remove them.": "Zuerst müssen die DKIM-Schlüssel entfernt werden, und Ihre Rolle darf sie nicht entfernen.",
|
||||
"The server stops accepting mail for this domain.": "Der Server nimmt keine E-Mails mehr für diese Domain an.",
|
||||
"Remove domain…": "Domain entfernen…",
|
||||
"Remove {name}?": "{name} entfernen?",
|
||||
"Removed {name}": "{name} entfernt",
|
||||
"The server kept the domain: it is still used by {things}.": "Der Server hat die Domain behalten: Sie wird noch verwendet von {things}.",
|
||||
"Remove domain": "Domain entfernen",
|
||||
"The server stops accepting mail for this domain. This can't be undone.": "Der Server nimmt keine E-Mails mehr für diese Domain an. Dies kann nicht rückgängig gemacht werden.",
|
||||
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Wo Ihre Adressen liegen, und die DNS-Einträge, damit E-Mails ankommen und als vertrauenswürdig gelten.",
|
||||
"Search domains": "Domains durchsuchen",
|
||||
"No domains match": "Keine passenden Domains",
|
||||
"No domains yet": "Noch keine Domains",
|
||||
"DKIM": "DKIM",
|
||||
"Tenant": "Mandant",
|
||||
"Disabled": "Deaktiviert",
|
||||
"also {names}": "auch {names}",
|
||||
"The server did not say whether the domain was created.": "Der Server hat nicht mitgeteilt, ob die Domain angelegt wurde.",
|
||||
// ── Administration: refusals ───────────────────────────────────
|
||||
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Das ist kein gültiger Domainname. Verwenden Sie einen Namen wie example.com mit einer echten Top-Level-Domain.",
|
||||
"That isn't a valid email address. Use a full address, such as [email protected].": "Das ist keine gültige E-Mail-Adresse. Verwenden Sie eine vollständige Adresse wie [email protected].",
|
||||
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Das ist keine gültige Adresse. Verwenden Sie vor dem @ Buchstaben, Ziffern, Punkte, Bindestriche oder Unterstriche.",
|
||||
"That isn't a valid host name or IP address.": "Das ist kein gültiger Hostname und keine gültige IP-Adresse.",
|
||||
"A required value was left empty.": "Ein erforderlicher Wert wurde leer gelassen.",
|
||||
"Administration is turned off on this installation.": "Die Verwaltung ist in dieser Installation deaktiviert.",
|
||||
"The mail server could not carry out the request ({code}).": "Der Mailserver konnte die Anfrage nicht ausführen ({code}).",
|
||||
"You can't give an account permissions your own role doesn't have.": "Sie können einem Konto keine Berechtigungen geben, die Ihre eigene Rolle nicht hat.",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "Dieses Konto meldet sich über ein externes Verzeichnis an, daher kann sein Passwort hier nicht festgelegt werden.",
|
||||
"The server's licence allows no more accounts.": "Die Lizenz des Servers erlaubt keine weiteren Konten.",
|
||||
"That domain name is already in use on this server, as a domain or another domain's other name.": "Dieser Domainname wird auf diesem Server bereits verwendet – als Domain oder als weiterer Name einer anderen Domain.",
|
||||
"Your organisation has reached the number of domains it is allowed.": "Ihre Organisation hat die Anzahl der erlaubten Domains erreicht.",
|
||||
"That is more than the mail server accepts in one change.": "Das ist mehr, als der Mailserver in einer Änderung annimmt.",
|
||||
"The mail server rejected one of the values. Check what you entered and try again.": "Der Mailserver hat einen der Werte abgelehnt. Prüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
|
||||
"The mail server refused the change ({code}).": "Der Mailserver hat die Änderung abgelehnt ({code}).",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Nur auf einem Gerät, das Sie als Ihr eigenes markiert haben. Melden Sie sich erneut an und setzen Sie das Häkchen bei „Das ist mein eigenes Gerät“.",
|
||||
"Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.",
|
||||
"Administration": "Verwaltung",
|
||||
"Directory": "Verzeichnis",
|
||||
"User": "Benutzer",
|
||||
"Administrator": "Administrator",
|
||||
"Custom role": "Eigene Rolle",
|
||||
"New account": "Neues Konto",
|
||||
"The people who sign in to mail on the domains you manage.": "Die Personen, die sich auf den von Ihnen verwalteten Domains bei ihrer E-Mail anmelden.",
|
||||
"Search by name or address": "Nach Name oder Adresse suchen",
|
||||
"Search accounts": "Konten durchsuchen",
|
||||
"No accounts match": "Keine passenden Konten",
|
||||
"No accounts yet": "Noch keine Konten",
|
||||
"Nothing on your domains matches “{query}”.": "Auf Ihren Domains passt nichts zu „{query}“.",
|
||||
"Open {address}": "{address} öffnen",
|
||||
"{from}–{to} of {total}": "{from}–{to} von {total}",
|
||||
"Previous page": "Vorherige Seite",
|
||||
"Next page": "Nächste Seite",
|
||||
"Storage": "Speicher",
|
||||
"Groups": "Gruppen",
|
||||
"{used} · no limit": "{used} · ohne Begrenzung",
|
||||
"Profile": "Profil",
|
||||
"Domain": "Domain",
|
||||
"No domains are available to create an account on.": "Es gibt keine Domain, auf der ein Konto angelegt werden kann.",
|
||||
"Sign-in": "Anmeldung",
|
||||
"Other addresses": "Weitere Adressen",
|
||||
"Not in any group": "In keiner Gruppe",
|
||||
"You can't change your own role.": "Sie können Ihre eigene Rolle nicht ändern.",
|
||||
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Angeboten werden nur Rollen, deren Berechtigungen Sie selbst besitzen. Bei einem Konto innerhalb eines Mandanten bedeutet Administrator: Administrator dieses Mandanten.",
|
||||
"Limit in GB": "Begrenzung in GB",
|
||||
"No limit": "Ohne Begrenzung",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "Dieses Konto hat Berechtigungen, die Ihres nicht hat. Sie können es ansehen, aber nicht ändern.",
|
||||
"Your role lets you view accounts but not change them.": "Ihre Rolle erlaubt es, Konten anzusehen, aber nicht zu ändern.",
|
||||
"This account has permissions yours doesn't.": "Dieses Konto hat Berechtigungen, die Ihres nicht hat.",
|
||||
"You can't delete the account you're signed in with.": "Das Konto, mit dem Sie angemeldet sind, können Sie nicht löschen.",
|
||||
"Create account": "Konto anlegen",
|
||||
"An account needs an address.": "Ein Konto braucht eine Adresse.",
|
||||
"Created {address}": "{address} angelegt",
|
||||
"Saved {address}": "{address} gespeichert",
|
||||
"Generate a password": "Passwort erzeugen",
|
||||
"Pass it on some way other than email to this address.": "Geben Sie es nicht per E-Mail an diese Adresse weiter.",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "Dieses Konto hat kein Passwort. Möglicherweise meldet es sich über ein Verzeichnis oder Single Sign-on an.",
|
||||
"Set a new password…": "Neues Passwort festlegen…",
|
||||
"{name} will be signed out of every app and device using the old password.": "{name} wird in allen Apps und auf allen Geräten abgemeldet, die das alte Passwort verwenden.",
|
||||
"New password set for {address}": "Neues Passwort für {address} festgelegt",
|
||||
"Set password": "Passwort festlegen",
|
||||
"Remove {address}": "{address} entfernen",
|
||||
"New address": "Neue Adresse",
|
||||
"another name": "anderer Name",
|
||||
"Mail to these addresses is delivered to this account. Changes apply when you save.": "E-Mails an diese Adressen werden diesem Konto zugestellt. Änderungen gelten nach dem Speichern.",
|
||||
"Deletes the mailbox and everything in it.": "Löscht das Postfach und alles darin.",
|
||||
"Delete account…": "Konto löschen…",
|
||||
"Delete {address}?": "{address} löschen?",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Dadurch werden die E-Mails, Kalender, Kontakte und Dateien dieses Kontos gelöscht. Der Server entfernt sie im Hintergrund, und es kann nicht rückgängig gemacht werden.",
|
||||
"Type {address} to confirm": "Zur Bestätigung {address} eingeben",
|
||||
"Delete account": "Konto löschen",
|
||||
"Deleted {address}": "{address} gelöscht",
|
||||
"The server did not say whether the account was created.": "Der Server hat nicht mitgeteilt, ob das Konto angelegt wurde.",
|
||||
"The mail server refused this. Your role may not allow it.": "Der Mailserver hat dies abgelehnt. Ihre Rolle erlaubt es möglicherweise nicht.",
|
||||
"That address is already in use on this server, as an account, a list or an alias.": "Diese Adresse wird auf diesem Server bereits verwendet – als Konto, Liste oder Alias.",
|
||||
"One of the chosen domain, role or group can't be used for this account.": "Die gewählte Domain, Rolle oder Gruppe kann für dieses Konto nicht verwendet werden.",
|
||||
"Your organisation has reached the number of accounts it is allowed.": "Ihre Organisation hat die Anzahl der erlaubten Konten erreicht.",
|
||||
"Something still depends on this, so the server kept it.": "Etwas hängt noch davon ab, daher hat der Server es behalten.",
|
||||
"This account no longer exists. Someone may have deleted it.": "Dieses Konto existiert nicht mehr. Möglicherweise hat es jemand gelöscht.",
|
||||
"The password was not accepted: {reason}": "Das Passwort wurde nicht akzeptiert: {reason}",
|
||||
"The password was not accepted.": "Das Passwort wurde nicht akzeptiert.",
|
||||
"Go to folder…": "Zu Ordner springen…",
|
||||
"Set for everyone here. You cannot change this.": "Für alle hier festgelegt. Sie können dies nicht ändern.",
|
||||
"Export iCAL file": "iCAL-Datei exportieren",
|
||||
@@ -327,7 +467,6 @@ export const catalog: Catalog = {
|
||||
"Busy": "Gebucht",
|
||||
"Free/busy": "Frei/Gebucht",
|
||||
"Show as": "Anzeigen als",
|
||||
"Availability on {date}": "Verfügbarkeit am {date}",
|
||||
"Count all events as busy": "Alle Termine als gebucht zählen",
|
||||
"Only events I'm attending": "Nur Termine, an denen ich teilnehme",
|
||||
"Don't include in availability": "Nicht in die Verfügbarkeit einbeziehen",
|
||||
@@ -366,7 +505,6 @@ export const catalog: Catalog = {
|
||||
"New address book": "Neues Adressbuch",
|
||||
"No address books yet.": "Noch keine Adressbücher.",
|
||||
"Choose from address books": "Aus Adressbüchern wählen",
|
||||
"Import vCard": "vCard importieren",
|
||||
"Export all contacts": "Alle Kontakte exportieren",
|
||||
"Export address book": "Dieses Adressbuch exportieren",
|
||||
"Import contacts…": "Kontakte importieren…",
|
||||
@@ -437,7 +575,6 @@ export const catalog: Catalog = {
|
||||
"Make ihasmail yours.": "Machen Sie ihasmail zu Ihrem.",
|
||||
"Reading": "Lesen",
|
||||
"Reading pane": "Lesebereich",
|
||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Verhalten beim Lesen, Senden und in der Liste. Die Einstellungen werden in diesem Browser gespeichert.",
|
||||
"Right of the list": "Rechts von der Liste",
|
||||
"Below the list": "Unter der Liste",
|
||||
"Hidden (open full width)": "Ausgeblendet (in voller Breite öffnen)",
|
||||
@@ -714,8 +851,6 @@ export const catalog: Catalog = {
|
||||
"Manage labels": "Labels verwalten",
|
||||
"Create “{name}”": "„{name}“ erstellen",
|
||||
"Type a name to create your first label.": "Geben Sie einen Namen ein, um Ihr erstes Label zu erstellen.",
|
||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Labels sind IMAP-Schlüsselwörter, die in Ihren Nachrichten gespeichert werden und daher mit anderen Clients synchronisiert werden. Namen und Farben bleiben in diesem Browser.",
|
||||
"PDF": "PDF",
|
||||
"Large attachments may be rejected by some servers": "Große Anhänge werden von manchen Servern abgelehnt",
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Bilder werden in Ihren Dateien (Ordner „ihasmail“) gespeichert und beim Senden eingebettet.",
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Vielen Dank für Ihre Nachricht. Ich bin bis … abwesend und melde mich nach meiner Rückkehr.",
|
||||
@@ -839,16 +974,11 @@ export const catalog: Catalog = {
|
||||
"Drop here for the top level": "Hierher ziehen für die oberste Ebene",
|
||||
|
||||
// ── Remaining prose ────────────────────────────────────────────────
|
||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} ist die Farbpalette von {site} und das, womit ein neues Konto startet. Es ist ein dunkles Design und zählt daher überall dort als dunkel, wo das eine Rolle spielt; die Akzentfarbe unten wirkt weiterhin darauf.",
|
||||
"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.",
|
||||
|
||||
// ── Weekdays, schedule presets, rule operators ─────────────────────
|
||||
// Header names (List-Id, X-Spam-Status) stay English: they are the actual
|
||||
// field names in the message, not words.
|
||||
"Tuesday": "Dienstag",
|
||||
"Wednesday": "Mittwoch",
|
||||
"Thursday": "Donnerstag",
|
||||
"Friday": "Freitag",
|
||||
"Later today": "Später heute",
|
||||
"Tomorrow morning": "Morgen früh",
|
||||
"Tomorrow afternoon": "Morgen Nachmittag",
|
||||
@@ -1385,6 +1515,15 @@ export const catalog: Catalog = {
|
||||
"no address": "keine Adresse",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} Konto verwendet diese Domain. Verschieben oder löschen Sie es zuerst.", other: "{n} Konten verwenden diese Domain. Verschieben oder löschen Sie sie zuerst." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Der Server nimmt keine E-Mails mehr für diese Domain an, und ihr {n} DKIM-Schlüssel wird gelöscht. Dies kann nicht rückgängig gemacht werden.", other: "Der Server nimmt keine E-Mails mehr für diese Domain an, und ihre {n} DKIM-Schlüssel werden gelöscht. Dies kann nicht rückgängig gemacht werden." },
|
||||
"{n} domains": { one: "{n} Domain", other: "{n} Domains" },
|
||||
"{n} mailing lists": { one: "{n} Mailingliste", other: "{n} Mailinglisten" },
|
||||
"{n} DKIM keys": { one: "{n} DKIM-Schlüssel", other: "{n} DKIM-Schlüssel" },
|
||||
"{n} other items": { one: "{n} weiteres Objekt", other: "{n} weitere Objekte" },
|
||||
// ── Administration ────────────────────────────────────────────────
|
||||
"{n} accounts": { one: "{n} Konto", other: "{n} Konten" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
"Delete {n} items": { one: "{n} Element löschen", other: "{n} Elemente löschen" },
|
||||
"Delete {n} items?": { one: "{n} Element löschen?", other: "{n} Elemente löschen?" },
|
||||
|
||||
+149
-10
@@ -47,6 +47,146 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"Domains": "Dominios",
|
||||
"By hand": "Manual",
|
||||
"Signing": "Firmando",
|
||||
"Published, not signing yet": "Publicada, aún no firma",
|
||||
"Retiring": "Retirándose",
|
||||
"Retired": "Retirada",
|
||||
"This domain no longer exists. Someone may have removed it.": "Este dominio ya no existe. Puede que alguien lo haya quitado.",
|
||||
"That doesn't look like a domain name, such as example.com.": "Eso no parece un nombre de dominio, como example.com.",
|
||||
"Added {name}. Its DNS records are ready to copy.": "{name} añadido. Sus registros DNS están listos para copiar.",
|
||||
"Saved {name}": "{name} guardado",
|
||||
"Add domain": "Añadir dominio",
|
||||
"Added {date}": "Añadido el {date}",
|
||||
"This domain is disabled on the server.": "Este dominio está desactivado en el servidor.",
|
||||
"Your role lets you view domains but not change them.": "Su rol le permite ver los dominios, pero no modificarlos.",
|
||||
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Los dominios nuevos firman su correo con claves DKIM que el servidor crea y renueva. Sus registros DNS aparecen aquí una vez añadido.",
|
||||
"Other names": "Otros nombres",
|
||||
"Delivery": "Entrega",
|
||||
"Catch-all address": "Dirección comodín",
|
||||
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "El correo a una dirección que nadie tiene en este dominio se entrega aquí. Déjelo vacío para rechazarlo.",
|
||||
"Plus addressing": "Subdirecciones con +",
|
||||
"Set by a custom rule on the server.": "Definido por una regla personalizada en el servidor.",
|
||||
"Mail to name+anything@ is delivered to name@.": "El correo a nombre+loquesea@ se entrega a nombre@.",
|
||||
"DNS records": "Registros DNS",
|
||||
"Published automatically through {provider}.": "Publicados automáticamente mediante {provider}.",
|
||||
"Published automatically by the server.": "Publicados automáticamente por el servidor.",
|
||||
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Añádalos donde esté alojado el DNS de este dominio. El correo no se entrega ni se considera fiable hasta que estén.",
|
||||
"Copy {type} record for {name}": "Copiar el registro {type} de {name}",
|
||||
"Copy value": "Copiar valor",
|
||||
"Copied the zone file": "Archivo de zona copiado",
|
||||
"Copy all as a zone file": "Copiar todo como archivo de zona",
|
||||
"The server returned no records for this domain.": "El servidor no devolvió registros para este dominio.",
|
||||
"DKIM keys": "Claves DKIM",
|
||||
"The server creates and rotates these keys itself.": "El servidor crea y renueva estas claves por sí mismo.",
|
||||
"These keys are managed by hand on the server.": "Estas claves se gestionan manualmente en el servidor.",
|
||||
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "No hay claves DKIM, así que el correo de este dominio no se firma y es más probable que se marque como spam.",
|
||||
"Managed by the server": "Gestionado por el servidor",
|
||||
"Certificate": "Certificado",
|
||||
"Another name for this domain": "Otro nombre para este dominio",
|
||||
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "El correo a la misma dirección con cualquiera de estos nombres llega a la misma cuenta. Los cambios se aplican al guardar.",
|
||||
"Its DKIM keys have to be removed first, and your role can't remove them.": "Primero hay que quitar sus claves DKIM, y su rol no puede quitarlas.",
|
||||
"The server stops accepting mail for this domain.": "El servidor deja de aceptar correo para este dominio.",
|
||||
"Remove domain…": "Quitar dominio…",
|
||||
"Remove {name}?": "¿Quitar {name}?",
|
||||
"Removed {name}": "{name} quitado",
|
||||
"The server kept the domain: it is still used by {things}.": "El servidor ha conservado el dominio: todavía lo usa {things}.",
|
||||
"Remove domain": "Quitar dominio",
|
||||
"The server stops accepting mail for this domain. This can't be undone.": "El servidor deja de aceptar correo para este dominio. No se puede deshacer.",
|
||||
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Dónde viven sus direcciones y los registros DNS que permiten que el correo llegue y sea de confianza.",
|
||||
"Search domains": "Buscar dominios",
|
||||
"No domains match": "Ningún dominio coincide",
|
||||
"No domains yet": "Todavía no hay dominios",
|
||||
"DKIM": "DKIM",
|
||||
"Tenant": "Inquilino",
|
||||
"Disabled": "Desactivado",
|
||||
"also {names}": "también {names}",
|
||||
"The server did not say whether the domain was created.": "El servidor no indicó si el dominio se creó.",
|
||||
// ── Administration: refusals ───────────────────────────────────
|
||||
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Ese no es un nombre de dominio válido. Use un nombre como example.com, con un dominio de nivel superior real.",
|
||||
"That isn't a valid email address. Use a full address, such as [email protected].": "Esa no es una dirección de correo válida. Use una dirección completa, como [email protected].",
|
||||
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Esa no es una dirección válida. Use letras, números, puntos, guiones o guiones bajos antes de la @.",
|
||||
"That isn't a valid host name or IP address.": "Ese no es un nombre de host ni una dirección IP válidos.",
|
||||
"A required value was left empty.": "Se dejó vacío un valor obligatorio.",
|
||||
"Administration is turned off on this installation.": "La administración está desactivada en esta instalación.",
|
||||
"The mail server could not carry out the request ({code}).": "El servidor de correo no pudo completar la solicitud ({code}).",
|
||||
"You can't give an account permissions your own role doesn't have.": "No puede dar a una cuenta permisos que su propio rol no tiene.",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "Esta cuenta inicia sesión mediante un directorio externo, así que su contraseña no se puede establecer aquí.",
|
||||
"The server's licence allows no more accounts.": "La licencia del servidor no permite más cuentas.",
|
||||
"That domain name is already in use on this server, as a domain or another domain's other name.": "Ese nombre de dominio ya está en uso en este servidor, como dominio o como otro nombre de otro dominio.",
|
||||
"Your organisation has reached the number of domains it is allowed.": "Su organización ha alcanzado el número de dominios permitido.",
|
||||
"That is more than the mail server accepts in one change.": "Eso supera lo que el servidor de correo acepta en un solo cambio.",
|
||||
"The mail server rejected one of the values. Check what you entered and try again.": "El servidor de correo ha rechazado uno de los valores. Revise lo que ha escrito e inténtelo de nuevo.",
|
||||
"The mail server refused the change ({code}).": "El servidor de correo ha rechazado el cambio ({code}).",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Solo en un dispositivo que haya marcado como suyo. Vuelva a iniciar sesión con «Este es mi propio dispositivo» marcado.",
|
||||
"Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.",
|
||||
"Administration": "Administración",
|
||||
"Directory": "Directorio",
|
||||
"User": "Usuario",
|
||||
"Administrator": "Administrador",
|
||||
"Custom role": "Rol personalizado",
|
||||
"New account": "Nueva cuenta",
|
||||
"The people who sign in to mail on the domains you manage.": "Las personas que inician sesión en el correo en los dominios que usted administra.",
|
||||
"Search by name or address": "Buscar por nombre o dirección",
|
||||
"Search accounts": "Buscar cuentas",
|
||||
"No accounts match": "Ninguna cuenta coincide",
|
||||
"No accounts yet": "Todavía no hay cuentas",
|
||||
"Nothing on your domains matches “{query}”.": "Nada en sus dominios coincide con «{query}».",
|
||||
"Open {address}": "Abrir {address}",
|
||||
"{from}–{to} of {total}": "{from}–{to} de {total}",
|
||||
"Previous page": "Página anterior",
|
||||
"Next page": "Página siguiente",
|
||||
"Storage": "Almacenamiento",
|
||||
"Groups": "Grupos",
|
||||
"{used} · no limit": "{used} · sin límite",
|
||||
"Profile": "Perfil",
|
||||
"Domain": "Dominio",
|
||||
"No domains are available to create an account on.": "No hay ningún dominio disponible en el que crear una cuenta.",
|
||||
"Sign-in": "Inicio de sesión",
|
||||
"Other addresses": "Otras direcciones",
|
||||
"Not in any group": "No pertenece a ningún grupo",
|
||||
"You can't change your own role.": "No puede cambiar su propio rol.",
|
||||
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Solo se ofrecen los roles cuyos permisos usted tiene. En una cuenta dentro de un inquilino, Administrador significa administrador de ese inquilino.",
|
||||
"Limit in GB": "Límite en GB",
|
||||
"No limit": "Sin límite",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "Esta cuenta tiene permisos que la suya no tiene, así que puede verla pero no modificarla.",
|
||||
"Your role lets you view accounts but not change them.": "Su rol le permite ver las cuentas, pero no modificarlas.",
|
||||
"This account has permissions yours doesn't.": "Esta cuenta tiene permisos que la suya no tiene.",
|
||||
"You can't delete the account you're signed in with.": "No puede eliminar la cuenta con la que ha iniciado sesión.",
|
||||
"Create account": "Crear cuenta",
|
||||
"An account needs an address.": "Una cuenta necesita una dirección.",
|
||||
"Created {address}": "{address} creada",
|
||||
"Saved {address}": "{address} guardada",
|
||||
"Generate a password": "Generar una contraseña",
|
||||
"Pass it on some way other than email to this address.": "Comuníquela por otro medio que no sea un correo a esta dirección.",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "Esta cuenta no tiene contraseña. Puede que inicie sesión mediante un directorio o un inicio de sesión único.",
|
||||
"Set a new password…": "Establecer una contraseña nueva…",
|
||||
"{name} will be signed out of every app and device using the old password.": "Se cerrará la sesión de {name} en todas las aplicaciones y dispositivos que usen la contraseña anterior.",
|
||||
"New password set for {address}": "Nueva contraseña establecida para {address}",
|
||||
"Set password": "Establecer contraseña",
|
||||
"Remove {address}": "Quitar {address}",
|
||||
"New address": "Nueva dirección",
|
||||
"another name": "otro nombre",
|
||||
"Mail to these addresses is delivered to this account. Changes apply when you save.": "El correo enviado a estas direcciones se entrega a esta cuenta. Los cambios se aplican al guardar.",
|
||||
"Deletes the mailbox and everything in it.": "Elimina el buzón y todo su contenido.",
|
||||
"Delete account…": "Eliminar cuenta…",
|
||||
"Delete {address}?": "¿Eliminar {address}?",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Esto elimina el correo, los calendarios, los contactos y los archivos de esta cuenta. El servidor los borra en segundo plano y no se puede deshacer.",
|
||||
"Type {address} to confirm": "Escriba {address} para confirmar",
|
||||
"Delete account": "Eliminar cuenta",
|
||||
"Deleted {address}": "{address} eliminada",
|
||||
"The server did not say whether the account was created.": "El servidor no indicó si la cuenta se creó.",
|
||||
"The mail server refused this. Your role may not allow it.": "El servidor de correo lo ha rechazado. Es posible que su rol no lo permita.",
|
||||
"That address is already in use on this server, as an account, a list or an alias.": "Esa dirección ya está en uso en este servidor, como cuenta, lista o alias.",
|
||||
"One of the chosen domain, role or group can't be used for this account.": "El dominio, el rol o el grupo elegido no se puede usar para esta cuenta.",
|
||||
"Your organisation has reached the number of accounts it is allowed.": "Su organización ha alcanzado el número de cuentas permitido.",
|
||||
"Something still depends on this, so the server kept it.": "Algo todavía depende de esto, así que el servidor lo ha conservado.",
|
||||
"This account no longer exists. Someone may have deleted it.": "Esta cuenta ya no existe. Puede que alguien la haya eliminado.",
|
||||
"The password was not accepted: {reason}": "La contraseña no se ha aceptado: {reason}",
|
||||
"The password was not accepted.": "La contraseña no se ha aceptado.",
|
||||
"Go to folder…": "Ir a la carpeta…",
|
||||
"Set for everyone here. You cannot change this.": "Definido para todos aquí. No puedes cambiarlo.",
|
||||
"Export iCAL file": "Exportar archivo iCAL",
|
||||
@@ -319,7 +459,6 @@ export const catalog: Catalog = {
|
||||
"Busy": "Ocupado",
|
||||
"Free/busy": "Disponibilidad",
|
||||
"Show as": "Mostrar como",
|
||||
"Availability on {date}": "Disponibilidad el {date}",
|
||||
"Count all events as busy": "Contar todos los eventos como ocupado",
|
||||
"Only events I'm attending": "Solo los eventos a los que asisto",
|
||||
"Don't include in availability": "No incluir en la disponibilidad",
|
||||
@@ -358,7 +497,6 @@ export const catalog: Catalog = {
|
||||
"New address book": "Libreta de direcciones nueva",
|
||||
"No address books yet.": "Aún no hay libretas de direcciones.",
|
||||
"Choose from address books": "Elegir de las libretas de direcciones",
|
||||
"Import vCard": "Importar una vCard",
|
||||
"Export all contacts": "Exportar todos los contactos",
|
||||
"Export address book": "Exportar esta libreta de direcciones",
|
||||
"Import contacts…": "Importar contactos…",
|
||||
@@ -432,7 +570,6 @@ export const catalog: Catalog = {
|
||||
"Make ihasmail yours.": "Haga suyo ihasmail.",
|
||||
"Reading": "Lectura",
|
||||
"Reading pane": "Panel de lectura",
|
||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Comportamiento de lectura, envío y lista. La configuración se guarda en este navegador.",
|
||||
"Right of the list": "A la derecha de la lista",
|
||||
"Below the list": "Debajo de la lista",
|
||||
"Hidden (open full width)": "Oculto (abrir a todo el ancho)",
|
||||
@@ -475,10 +612,6 @@ export const catalog: Catalog = {
|
||||
"Time zone": "Zona horaria",
|
||||
"Week starts on": "La semana empieza el",
|
||||
"Monday": "Lunes",
|
||||
"Tuesday": "Martes",
|
||||
"Wednesday": "Miércoles",
|
||||
"Thursday": "Jueves",
|
||||
"Friday": "Viernes",
|
||||
"Saturday": "Sábado",
|
||||
"Sunday": "Domingo",
|
||||
"12-hour clock (6:23 PM)": "Formato de 12 horas (6:23 PM)",
|
||||
@@ -725,10 +858,8 @@ export const catalog: Catalog = {
|
||||
"Manage labels": "Gestionar las etiquetas",
|
||||
"Create “{name}”": "Crear «{name}»",
|
||||
"Type a name to create your first label.": "Escriba un nombre para crear su primera etiqueta.",
|
||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Las etiquetas son palabras clave IMAP guardadas en sus mensajes, así que se sincronizan con otros clientes. Los nombres y colores se guardan en este navegador.",
|
||||
"New label": "Etiqueta nueva",
|
||||
"Delete label": "Eliminar la etiqueta",
|
||||
"PDF": "PDF",
|
||||
"Large attachments may be rejected by some servers": "Algunos servidores rechazan los adjuntos grandes",
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Las imágenes se guardan en sus Archivos (carpeta «ihasmail») y se incrustan al enviar.",
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Gracias por su mensaje. Estaré ausente hasta el … y le responderé a mi regreso.",
|
||||
@@ -862,7 +993,6 @@ 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.",
|
||||
"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}.",
|
||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} es la paleta de {site}, y con la que empieza una cuenta nueva. Es un tema oscuro, así que cuenta como oscuro allí donde importa, y el color de acento de abajo se sigue aplicando encima.",
|
||||
"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.",
|
||||
|
||||
// ── Composer status, calendar title ────────────────────────────────
|
||||
@@ -1358,6 +1488,15 @@ export const catalog: Catalog = {
|
||||
"no address": "ninguna dirección",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} cuenta usa este dominio. Muévala o elimínela primero.", other: "{n} cuentas usan este dominio. Muévalas o elimínelas primero." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "El servidor deja de aceptar correo para este dominio y se elimina su {n} clave DKIM. No se puede deshacer.", other: "El servidor deja de aceptar correo para este dominio y se eliminan sus {n} claves DKIM. No se puede deshacer." },
|
||||
"{n} domains": { one: "{n} dominio", other: "{n} dominios" },
|
||||
"{n} mailing lists": { one: "{n} lista de correo", other: "{n} listas de correo" },
|
||||
"{n} DKIM keys": { one: "{n} clave DKIM", other: "{n} claves DKIM" },
|
||||
"{n} other items": { one: "{n} elemento más", other: "{n} elementos más" },
|
||||
// ── Administration ────────────────────────────────────────────────
|
||||
"{n} accounts": { one: "{n} cuenta", other: "{n} cuentas" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
"Delete {n} items": { one: "Eliminar {n} elemento", other: "Eliminar {n} elementos" },
|
||||
"Delete {n} items?": { one: "¿Eliminar {n} elemento?", other: "¿Eliminar {n} elementos?" },
|
||||
|
||||
+149
-10
@@ -52,6 +52,146 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"Domains": "Domaines",
|
||||
"By hand": "Manuel",
|
||||
"Signing": "Signe",
|
||||
"Published, not signing yet": "Publiée, ne signe pas encore",
|
||||
"Retiring": "En retrait",
|
||||
"Retired": "Retirée",
|
||||
"This domain no longer exists. Someone may have removed it.": "Ce domaine n’existe plus. Quelqu’un l’a peut-être retiré.",
|
||||
"That doesn't look like a domain name, such as example.com.": "Cela ne ressemble pas à un nom de domaine, comme example.com.",
|
||||
"Added {name}. Its DNS records are ready to copy.": "{name} ajouté. Ses enregistrements DNS sont prêts à être copiés.",
|
||||
"Saved {name}": "{name} enregistré",
|
||||
"Add domain": "Ajouter un domaine",
|
||||
"Added {date}": "Ajouté le {date}",
|
||||
"This domain is disabled on the server.": "Ce domaine est désactivé sur le serveur.",
|
||||
"Your role lets you view domains but not change them.": "Votre rôle vous permet de consulter les domaines, mais pas de les modifier.",
|
||||
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Les nouveaux domaines signent leurs messages avec des clés DKIM que le serveur crée et renouvelle. Leurs enregistrements DNS apparaissent ici une fois le domaine ajouté.",
|
||||
"Other names": "Autres noms",
|
||||
"Delivery": "Distribution",
|
||||
"Catch-all address": "Adresse fourre-tout",
|
||||
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "Les messages envoyés à une adresse que personne n’a sur ce domaine sont remis ici. Laissez vide pour les refuser.",
|
||||
"Plus addressing": "Adresses avec +",
|
||||
"Set by a custom rule on the server.": "Défini par une règle personnalisée sur le serveur.",
|
||||
"Mail to name+anything@ is delivered to name@.": "Les messages à nom+nimportequoi@ sont remis à nom@.",
|
||||
"DNS records": "Enregistrements DNS",
|
||||
"Published automatically through {provider}.": "Publiés automatiquement via {provider}.",
|
||||
"Published automatically by the server.": "Publiés automatiquement par le serveur.",
|
||||
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Ajoutez-les là où le DNS de ce domaine est hébergé. Les messages ne sont ni distribués ni jugés fiables tant qu’ils ne sont pas en place.",
|
||||
"Copy {type} record for {name}": "Copier l’enregistrement {type} pour {name}",
|
||||
"Copy value": "Copier la valeur",
|
||||
"Copied the zone file": "Fichier de zone copié",
|
||||
"Copy all as a zone file": "Tout copier en fichier de zone",
|
||||
"The server returned no records for this domain.": "Le serveur n’a renvoyé aucun enregistrement pour ce domaine.",
|
||||
"DKIM keys": "Clés DKIM",
|
||||
"The server creates and rotates these keys itself.": "Le serveur crée et renouvelle ces clés lui-même.",
|
||||
"These keys are managed by hand on the server.": "Ces clés sont gérées manuellement sur le serveur.",
|
||||
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Aucune clé DKIM : les messages de ce domaine ne sont pas signés et risquent davantage d’être classés comme spam.",
|
||||
"Managed by the server": "Géré par le serveur",
|
||||
"Certificate": "Certificat",
|
||||
"Another name for this domain": "Autre nom pour ce domaine",
|
||||
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "Les messages envoyés à la même adresse sous l’un de ces noms arrivent dans le même compte. Les modifications s’appliquent à l’enregistrement.",
|
||||
"Its DKIM keys have to be removed first, and your role can't remove them.": "Ses clés DKIM doivent d’abord être supprimées, et votre rôle ne le permet pas.",
|
||||
"The server stops accepting mail for this domain.": "Le serveur n’accepte plus de messages pour ce domaine.",
|
||||
"Remove domain…": "Retirer le domaine…",
|
||||
"Remove {name}?": "Retirer {name} ?",
|
||||
"Removed {name}": "{name} retiré",
|
||||
"The server kept the domain: it is still used by {things}.": "Le serveur a conservé le domaine : il est encore utilisé par {things}.",
|
||||
"Remove domain": "Retirer le domaine",
|
||||
"The server stops accepting mail for this domain. This can't be undone.": "Le serveur n’accepte plus de messages pour ce domaine. C’est irréversible.",
|
||||
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Là où vivent vos adresses, et les enregistrements DNS qui permettent aux messages d’arriver et d’être fiables.",
|
||||
"Search domains": "Rechercher des domaines",
|
||||
"No domains match": "Aucun domaine correspondant",
|
||||
"No domains yet": "Aucun domaine pour l’instant",
|
||||
"DKIM": "DKIM",
|
||||
"Tenant": "Locataire",
|
||||
"Disabled": "Désactivé",
|
||||
"also {names}": "aussi {names}",
|
||||
"The server did not say whether the domain was created.": "Le serveur n’a pas indiqué si le domaine a été créé.",
|
||||
// ── Administration: refusals ───────────────────────────────────
|
||||
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Ce n’est pas un nom de domaine valide. Utilisez un nom comme example.com, avec un vrai domaine de premier niveau.",
|
||||
"That isn't a valid email address. Use a full address, such as [email protected].": "Ce n’est pas une adresse e-mail valide. Utilisez une adresse complète, comme [email protected].",
|
||||
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Ce n’est pas une adresse valide. Utilisez des lettres, des chiffres, des points, des tirets ou des traits de soulignement avant le @.",
|
||||
"That isn't a valid host name or IP address.": "Ce n’est ni un nom d’hôte ni une adresse IP valide.",
|
||||
"A required value was left empty.": "Une valeur obligatoire a été laissée vide.",
|
||||
"Administration is turned off on this installation.": "L’administration est désactivée sur cette installation.",
|
||||
"The mail server could not carry out the request ({code}).": "Le serveur de messagerie n’a pas pu traiter la demande ({code}).",
|
||||
"You can't give an account permissions your own role doesn't have.": "Vous ne pouvez pas donner à un compte des autorisations que votre propre rôle n’a pas.",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "Ce compte se connecte via un annuaire externe, son mot de passe ne peut donc pas être défini ici.",
|
||||
"The server's licence allows no more accounts.": "La licence du serveur ne permet pas de comptes supplémentaires.",
|
||||
"That domain name is already in use on this server, as a domain or another domain's other name.": "Ce nom de domaine est déjà utilisé sur ce serveur, comme domaine ou comme autre nom d’un autre domaine.",
|
||||
"Your organisation has reached the number of domains it is allowed.": "Votre organisation a atteint le nombre de domaines autorisé.",
|
||||
"That is more than the mail server accepts in one change.": "C’est plus que ce que le serveur de messagerie accepte en une seule modification.",
|
||||
"The mail server rejected one of the values. Check what you entered and try again.": "Le serveur de messagerie a refusé l’une des valeurs. Vérifiez votre saisie et réessayez.",
|
||||
"The mail server refused the change ({code}).": "Le serveur de messagerie a refusé la modification ({code}).",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Uniquement sur un appareil que vous avez indiqué comme le vôtre. Reconnectez-vous en cochant « Cet appareil est le mien ».",
|
||||
"Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.",
|
||||
"Administration": "Administration",
|
||||
"Directory": "Annuaire",
|
||||
"User": "Utilisateur",
|
||||
"Administrator": "Administrateur",
|
||||
"Custom role": "Rôle personnalisé",
|
||||
"New account": "Nouveau compte",
|
||||
"The people who sign in to mail on the domains you manage.": "Les personnes qui se connectent à leur messagerie sur les domaines que vous gérez.",
|
||||
"Search by name or address": "Rechercher par nom ou adresse",
|
||||
"Search accounts": "Rechercher des comptes",
|
||||
"No accounts match": "Aucun compte correspondant",
|
||||
"No accounts yet": "Aucun compte pour l’instant",
|
||||
"Nothing on your domains matches “{query}”.": "Rien ne correspond à « {query} » sur vos domaines.",
|
||||
"Open {address}": "Ouvrir {address}",
|
||||
"{from}–{to} of {total}": "{from}–{to} sur {total}",
|
||||
"Previous page": "Page précédente",
|
||||
"Next page": "Page suivante",
|
||||
"Storage": "Stockage",
|
||||
"Groups": "Groupes",
|
||||
"{used} · no limit": "{used} · sans limite",
|
||||
"Profile": "Profil",
|
||||
"Domain": "Domaine",
|
||||
"No domains are available to create an account on.": "Aucun domaine n’est disponible pour créer un compte.",
|
||||
"Sign-in": "Connexion",
|
||||
"Other addresses": "Autres adresses",
|
||||
"Not in any group": "Membre d’aucun groupe",
|
||||
"You can't change your own role.": "Vous ne pouvez pas modifier votre propre rôle.",
|
||||
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Seuls les rôles dont vous détenez vous-même les autorisations sont proposés. Pour un compte au sein d’un locataire, Administrateur signifie administrateur de ce locataire.",
|
||||
"Limit in GB": "Limite en Go",
|
||||
"No limit": "Sans limite",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "Ce compte a des autorisations que le vôtre n’a pas : vous pouvez le consulter, mais pas le modifier.",
|
||||
"Your role lets you view accounts but not change them.": "Votre rôle vous permet de consulter les comptes, mais pas de les modifier.",
|
||||
"This account has permissions yours doesn't.": "Ce compte a des autorisations que le vôtre n’a pas.",
|
||||
"You can't delete the account you're signed in with.": "Vous ne pouvez pas supprimer le compte avec lequel vous êtes connecté.",
|
||||
"Create account": "Créer le compte",
|
||||
"An account needs an address.": "Un compte doit avoir une adresse.",
|
||||
"Created {address}": "{address} créé",
|
||||
"Saved {address}": "{address} enregistré",
|
||||
"Generate a password": "Générer un mot de passe",
|
||||
"Pass it on some way other than email to this address.": "Transmettez-le autrement que par e-mail à cette adresse.",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "Ce compte n’a pas de mot de passe. Il se connecte peut-être via un annuaire ou une authentification unique.",
|
||||
"Set a new password…": "Définir un nouveau mot de passe…",
|
||||
"{name} will be signed out of every app and device using the old password.": "{name} sera déconnecté de toutes les applications et de tous les appareils qui utilisent l’ancien mot de passe.",
|
||||
"New password set for {address}": "Nouveau mot de passe défini pour {address}",
|
||||
"Set password": "Définir le mot de passe",
|
||||
"Remove {address}": "Retirer {address}",
|
||||
"New address": "Nouvelle adresse",
|
||||
"another name": "autre nom",
|
||||
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Les messages envoyés à ces adresses sont remis à ce compte. Les modifications s’appliquent à l’enregistrement.",
|
||||
"Deletes the mailbox and everything in it.": "Supprime la boîte aux lettres et tout son contenu.",
|
||||
"Delete account…": "Supprimer le compte…",
|
||||
"Delete {address}?": "Supprimer {address} ?",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Cette action supprime les messages, agendas, contacts et fichiers de ce compte. Le serveur les efface en arrière-plan, et c’est irréversible.",
|
||||
"Type {address} to confirm": "Saisissez {address} pour confirmer",
|
||||
"Delete account": "Supprimer le compte",
|
||||
"Deleted {address}": "{address} supprimé",
|
||||
"The server did not say whether the account was created.": "Le serveur n’a pas indiqué si le compte a été créé.",
|
||||
"The mail server refused this. Your role may not allow it.": "Le serveur de messagerie a refusé. Votre rôle ne le permet peut-être pas.",
|
||||
"That address is already in use on this server, as an account, a list or an alias.": "Cette adresse est déjà utilisée sur ce serveur, par un compte, une liste ou un alias.",
|
||||
"One of the chosen domain, role or group can't be used for this account.": "Le domaine, le rôle ou le groupe choisi ne peut pas être utilisé pour ce compte.",
|
||||
"Your organisation has reached the number of accounts it is allowed.": "Votre organisation a atteint le nombre de comptes autorisé.",
|
||||
"Something still depends on this, so the server kept it.": "Un autre élément en dépend encore, le serveur l’a donc conservé.",
|
||||
"This account no longer exists. Someone may have deleted it.": "Ce compte n’existe plus. Quelqu’un l’a peut-être supprimé.",
|
||||
"The password was not accepted: {reason}": "Le mot de passe a été refusé : {reason}",
|
||||
"The password was not accepted.": "Le mot de passe a été refusé.",
|
||||
"Go to folder…": "Aller au dossier…",
|
||||
"Set for everyone here. You cannot change this.": "Défini pour tout le monde ici. Vous ne pouvez pas le modifier.",
|
||||
"Export iCAL file": "Exporter un fichier iCAL",
|
||||
@@ -324,7 +464,6 @@ export const catalog: Catalog = {
|
||||
"Busy": "Occupé",
|
||||
"Free/busy": "Disponibilité",
|
||||
"Show as": "Afficher comme",
|
||||
"Availability on {date}": "Disponibilité le {date}",
|
||||
"Count all events as busy": "Compter tous les événements comme occupé",
|
||||
"Only events I'm attending": "Uniquement les événements auxquels je participe",
|
||||
"Don't include in availability": "Ne pas inclure dans la disponibilité",
|
||||
@@ -363,7 +502,6 @@ export const catalog: Catalog = {
|
||||
"New address book": "Nouveau carnet d'adresses",
|
||||
"No address books yet.": "Aucun carnet d'adresses pour le moment.",
|
||||
"Choose from address books": "Choisir dans les carnets d'adresses",
|
||||
"Import vCard": "Importer une vCard",
|
||||
"Export all contacts": "Exporter tous les contacts",
|
||||
"Export address book": "Exporter ce carnet d’adresses",
|
||||
"Import contacts…": "Importer des contacts…",
|
||||
@@ -438,7 +576,6 @@ export const catalog: Catalog = {
|
||||
"Make ihasmail yours.": "Faites de ihasmail le vôtre.",
|
||||
"Reading": "Lecture",
|
||||
"Reading pane": "Volet de lecture",
|
||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Comportement de lecture, d'envoi et de liste. Les paramètres sont enregistrés dans ce navigateur.",
|
||||
"Right of the list": "À droite de la liste",
|
||||
"Below the list": "Sous la liste",
|
||||
"Hidden (open full width)": "Masqué (ouvrir en pleine largeur)",
|
||||
@@ -481,10 +618,6 @@ export const catalog: Catalog = {
|
||||
"Time zone": "Fuseau horaire",
|
||||
"Week starts on": "La semaine commence le",
|
||||
"Monday": "Lundi",
|
||||
"Tuesday": "Mardi",
|
||||
"Wednesday": "Mercredi",
|
||||
"Thursday": "Jeudi",
|
||||
"Friday": "Vendredi",
|
||||
"Saturday": "Samedi",
|
||||
"Sunday": "Dimanche",
|
||||
"12-hour clock (6:23 PM)": "Format 12 heures (6:23 PM)",
|
||||
@@ -730,10 +863,8 @@ export const catalog: Catalog = {
|
||||
"Manage labels": "Gérer les libellés",
|
||||
"Create “{name}”": "Créer « {name} »",
|
||||
"Type a name to create your first label.": "Saisissez un nom pour créer votre premier libellé.",
|
||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Les libellés sont des mots-clés IMAP enregistrés dans vos messages : ils se synchronisent donc avec les autres clients. Les noms et couleurs restent dans ce navigateur.",
|
||||
"New label": "Nouveau libellé",
|
||||
"Delete label": "Supprimer le libellé",
|
||||
"PDF": "PDF",
|
||||
"Large attachments may be rejected by some servers": "Les pièces jointes volumineuses peuvent être refusées par certains serveurs",
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Les images sont enregistrées dans vos Fichiers (dossier « ihasmail ») et intégrées à l'envoi.",
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Merci pour votre message. Je suis absent jusqu'au … et vous répondrai à mon retour.",
|
||||
@@ -867,7 +998,6 @@ 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.",
|
||||
"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}.",
|
||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} est la palette de {site}, et celle d'un nouveau compte. C'est un thème sombre : il compte donc comme sombre partout où cela importe, et la couleur d'accentuation ci-dessous s'y applique toujours.",
|
||||
"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.",
|
||||
|
||||
// ── Composer status, calendar title ────────────────────────────────
|
||||
@@ -1363,6 +1493,15 @@ export const catalog: Catalog = {
|
||||
"no address": "aucune adresse",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} compte utilise ce domaine. Déplacez-le ou supprimez-le d’abord.", other: "{n} comptes utilisent ce domaine. Déplacez-les ou supprimez-les d’abord." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Le serveur n’accepte plus de messages pour ce domaine, et sa {n} clé DKIM est supprimée. C’est irréversible.", other: "Le serveur n’accepte plus de messages pour ce domaine, et ses {n} clés DKIM sont supprimées. C’est irréversible." },
|
||||
"{n} domains": { one: "{n} domaine", other: "{n} domaines" },
|
||||
"{n} mailing lists": { one: "{n} liste de diffusion", other: "{n} listes de diffusion" },
|
||||
"{n} DKIM keys": { one: "{n} clé DKIM", other: "{n} clés DKIM" },
|
||||
"{n} other items": { one: "{n} autre élément", other: "{n} autres éléments" },
|
||||
// ── Administration ────────────────────────────────────────────────
|
||||
"{n} accounts": { one: "{n} compte", other: "{n} comptes" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
"Delete {n} items": { one: "Supprimer {n} élément", other: "Supprimer {n} éléments" },
|
||||
"Delete {n} items?": { one: "Supprimer {n} élément ?", other: "Supprimer {n} éléments ?" },
|
||||
|
||||
+149
-10
@@ -46,6 +46,146 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"Domains": "ドメイン",
|
||||
"By hand": "手動",
|
||||
"Signing": "署名中",
|
||||
"Published, not signing yet": "公開済み(まだ署名なし)",
|
||||
"Retiring": "廃止中",
|
||||
"Retired": "廃止済み",
|
||||
"This domain no longer exists. Someone may have removed it.": "このドメインはもう存在しません。誰かが削除した可能性があります。",
|
||||
"That doesn't look like a domain name, such as example.com.": "example.com のようなドメイン名ではないようです。",
|
||||
"Added {name}. Its DNS records are ready to copy.": "{name} を追加しました。DNS レコードをコピーできます。",
|
||||
"Saved {name}": "{name} を保存しました",
|
||||
"Add domain": "ドメインを追加",
|
||||
"Added {date}": "{date} に追加",
|
||||
"This domain is disabled on the server.": "このドメインはサーバーで無効になっています。",
|
||||
"Your role lets you view domains but not change them.": "あなたのロールでは、ドメインの閲覧はできますが変更はできません。",
|
||||
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "新しいドメインは、サーバーが作成・更新する DKIM 鍵でメールに署名します。DNS レコードはドメインを追加するとここに表示されます。",
|
||||
"Other names": "別名",
|
||||
"Delivery": "配信",
|
||||
"Catch-all address": "キャッチオールアドレス",
|
||||
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "このドメインで誰も持っていないアドレス宛てのメールをここに配信します。空欄にすると、そのメールは拒否されます。",
|
||||
"Plus addressing": "プラスアドレス",
|
||||
"Set by a custom rule on the server.": "サーバーのカスタムルールで設定されています。",
|
||||
"Mail to name+anything@ is delivered to name@.": "名前+任意@ 宛てのメールは 名前@ に配信されます。",
|
||||
"DNS records": "DNS レコード",
|
||||
"Published automatically through {provider}.": "{provider} を通じて自動で公開されます。",
|
||||
"Published automatically by the server.": "サーバーが自動で公開します。",
|
||||
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "このドメインの DNS を管理している場所に追加してください。追加されるまで、メールは配信されず信頼もされません。",
|
||||
"Copy {type} record for {name}": "{name} の {type} レコードをコピー",
|
||||
"Copy value": "値をコピー",
|
||||
"Copied the zone file": "ゾーンファイルをコピーしました",
|
||||
"Copy all as a zone file": "すべてゾーンファイルとしてコピー",
|
||||
"The server returned no records for this domain.": "サーバーはこのドメインのレコードを返しませんでした。",
|
||||
"DKIM keys": "DKIM 鍵",
|
||||
"The server creates and rotates these keys itself.": "サーバーがこれらの鍵を自動で作成・更新します。",
|
||||
"These keys are managed by hand on the server.": "これらの鍵はサーバーで手動管理されています。",
|
||||
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "DKIM 鍵がないため、このドメインのメールは署名されず、迷惑メールと判定されやすくなります。",
|
||||
"Managed by the server": "サーバーによる管理",
|
||||
"Certificate": "証明書",
|
||||
"Another name for this domain": "このドメインの別名",
|
||||
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "これらのいずれの名前でも、同じアドレス宛てのメールは同じアカウントに届きます。変更は保存時に反映されます。",
|
||||
"Its DKIM keys have to be removed first, and your role can't remove them.": "先に DKIM 鍵を削除する必要がありますが、あなたのロールでは削除できません。",
|
||||
"The server stops accepting mail for this domain.": "サーバーはこのドメイン宛てのメールを受け付けなくなります。",
|
||||
"Remove domain…": "ドメインを削除…",
|
||||
"Remove {name}?": "{name} を削除しますか?",
|
||||
"Removed {name}": "{name} を削除しました",
|
||||
"The server kept the domain: it is still used by {things}.": "サーバーはドメインを削除しませんでした。まだ {things} が使用しています。",
|
||||
"Remove domain": "ドメインを削除",
|
||||
"The server stops accepting mail for this domain. This can't be undone.": "サーバーはこのドメイン宛てのメールを受け付けなくなります。元に戻すことはできません。",
|
||||
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "アドレスの置き場所と、メールが届き信頼されるための DNS レコードです。",
|
||||
"Search domains": "ドメインを検索",
|
||||
"No domains match": "一致するドメインはありません",
|
||||
"No domains yet": "ドメインはまだありません",
|
||||
"DKIM": "DKIM",
|
||||
"Tenant": "テナント",
|
||||
"Disabled": "無効",
|
||||
"also {names}": "別名: {names}",
|
||||
"The server did not say whether the domain was created.": "ドメインが作成されたかどうか、サーバーから応答がありませんでした。",
|
||||
// ── Administration: refusals ───────────────────────────────────
|
||||
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "有効なドメイン名ではありません。example.com のように、実在するトップレベルドメインの名前を使ってください。",
|
||||
"That isn't a valid email address. Use a full address, such as [email protected].": "有効なメールアドレスではありません。[email protected] のような完全なアドレスを使ってください。",
|
||||
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "有効なアドレスではありません。@ の前には英数字、ドット、ハイフン、アンダースコアを使ってください。",
|
||||
"That isn't a valid host name or IP address.": "有効なホスト名または IP アドレスではありません。",
|
||||
"A required value was left empty.": "必須の値が空欄です。",
|
||||
"Administration is turned off on this installation.": "このインストールでは管理機能が無効になっています。",
|
||||
"The mail server could not carry out the request ({code}).": "メールサーバーはリクエストを実行できませんでした({code})。",
|
||||
"You can't give an account permissions your own role doesn't have.": "自分のロールにない権限をアカウントに付与することはできません。",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "このアカウントは外部ディレクトリでサインインするため、ここではパスワードを設定できません。",
|
||||
"The server's licence allows no more accounts.": "サーバーのライセンスでは、これ以上アカウントを追加できません。",
|
||||
"That domain name is already in use on this server, as a domain or another domain's other name.": "このドメイン名は、ドメインまたは別のドメインの別名としてこのサーバーで既に使われています。",
|
||||
"Your organisation has reached the number of domains it is allowed.": "組織で許可されているドメイン数の上限に達しました。",
|
||||
"That is more than the mail server accepts in one change.": "メールサーバーが一度の変更で受け付けられる量を超えています。",
|
||||
"The mail server rejected one of the values. Check what you entered and try again.": "メールサーバーが値のひとつを拒否しました。入力内容を確認して、もう一度お試しください。",
|
||||
"The mail server refused the change ({code}).": "メールサーバーが変更を拒否しました({code})。",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "自分のデバイスとして指定した端末でのみ使えます。「これは自分のデバイスです」にチェックを入れて、もう一度サインインしてください。",
|
||||
"Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。",
|
||||
"Administration": "管理",
|
||||
"Directory": "ディレクトリ",
|
||||
"User": "ユーザー",
|
||||
"Administrator": "管理者",
|
||||
"Custom role": "カスタムロール",
|
||||
"New account": "新しいアカウント",
|
||||
"The people who sign in to mail on the domains you manage.": "管理しているドメインでメールにサインインするユーザーです。",
|
||||
"Search by name or address": "名前またはアドレスで検索",
|
||||
"Search accounts": "アカウントを検索",
|
||||
"No accounts match": "一致するアカウントはありません",
|
||||
"No accounts yet": "アカウントはまだありません",
|
||||
"Nothing on your domains matches “{query}”.": "ドメイン内に「{query}」と一致するものはありません。",
|
||||
"Open {address}": "{address} を開く",
|
||||
"{from}–{to} of {total}": "{from}–{to} / {total}",
|
||||
"Previous page": "前のページ",
|
||||
"Next page": "次のページ",
|
||||
"Storage": "ストレージ",
|
||||
"Groups": "グループ",
|
||||
"{used} · no limit": "{used} · 上限なし",
|
||||
"Profile": "プロフィール",
|
||||
"Domain": "ドメイン",
|
||||
"No domains are available to create an account on.": "アカウントを作成できるドメインがありません。",
|
||||
"Sign-in": "サインイン",
|
||||
"Other addresses": "その他のアドレス",
|
||||
"Not in any group": "どのグループにも属していません",
|
||||
"You can't change your own role.": "自分のロールは変更できません。",
|
||||
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "ご自身が持つ権限だけで構成されたロールのみ表示されます。テナント内のアカウントでは、管理者はそのテナントの管理者を意味します。",
|
||||
"Limit in GB": "上限(GB)",
|
||||
"No limit": "上限なし",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "このアカウントにはあなたのアカウントにない権限があるため、閲覧はできますが変更はできません。",
|
||||
"Your role lets you view accounts but not change them.": "あなたのロールでは、アカウントの閲覧はできますが変更はできません。",
|
||||
"This account has permissions yours doesn't.": "このアカウントにはあなたのアカウントにない権限があります。",
|
||||
"You can't delete the account you're signed in with.": "サインイン中のアカウントは削除できません。",
|
||||
"Create account": "アカウントを作成",
|
||||
"An account needs an address.": "アカウントにはアドレスが必要です。",
|
||||
"Created {address}": "{address} を作成しました",
|
||||
"Saved {address}": "{address} を保存しました",
|
||||
"Generate a password": "パスワードを生成",
|
||||
"Pass it on some way other than email to this address.": "このアドレス宛てのメール以外の方法で伝えてください。",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "このアカウントにはパスワードがありません。ディレクトリやシングルサインオンでサインインしている可能性があります。",
|
||||
"Set a new password…": "新しいパスワードを設定…",
|
||||
"{name} will be signed out of every app and device using the old password.": "{name} は、古いパスワードを使っているすべてのアプリとデバイスからサインアウトされます。",
|
||||
"New password set for {address}": "{address} の新しいパスワードを設定しました",
|
||||
"Set password": "パスワードを設定",
|
||||
"Remove {address}": "{address} を削除",
|
||||
"New address": "新しいアドレス",
|
||||
"another name": "別の名前",
|
||||
"Mail to these addresses is delivered to this account. Changes apply when you save.": "これらのアドレス宛てのメールはこのアカウントに配信されます。変更は保存時に反映されます。",
|
||||
"Deletes the mailbox and everything in it.": "メールボックスとその中身をすべて削除します。",
|
||||
"Delete account…": "アカウントを削除…",
|
||||
"Delete {address}?": "{address} を削除しますか?",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "このアカウントのメール、カレンダー、連絡先、ファイルが削除されます。サーバーがバックグラウンドで削除し、元に戻すことはできません。",
|
||||
"Type {address} to confirm": "確認のため {address} と入力してください",
|
||||
"Delete account": "アカウントを削除",
|
||||
"Deleted {address}": "{address} を削除しました",
|
||||
"The server did not say whether the account was created.": "アカウントが作成されたかどうか、サーバーから応答がありませんでした。",
|
||||
"The mail server refused this. Your role may not allow it.": "メールサーバーに拒否されました。ロールで許可されていない可能性があります。",
|
||||
"That address is already in use on this server, as an account, a list or an alias.": "このアドレスは、アカウント、リスト、またはエイリアスとして、このサーバーですでに使われています。",
|
||||
"One of the chosen domain, role or group can't be used for this account.": "選択したドメイン、ロール、またはグループはこのアカウントには使えません。",
|
||||
"Your organisation has reached the number of accounts it is allowed.": "組織で許可されているアカウント数の上限に達しました。",
|
||||
"Something still depends on this, so the server kept it.": "まだこれに依存しているものがあるため、サーバーは削除しませんでした。",
|
||||
"This account no longer exists. Someone may have deleted it.": "このアカウントはもう存在しません。誰かが削除した可能性があります。",
|
||||
"The password was not accepted: {reason}": "パスワードは受け付けられませんでした: {reason}",
|
||||
"The password was not accepted.": "パスワードは受け付けられませんでした。",
|
||||
"Go to folder…": "フォルダーへ移動…",
|
||||
"Set for everyone here. You cannot change this.": "この環境全体で設定されています。変更できません。",
|
||||
"Export iCAL file": "iCAL ファイルをエクスポート",
|
||||
@@ -318,7 +458,6 @@ export const catalog: Catalog = {
|
||||
"Busy": "予定あり",
|
||||
"Free/busy": "空き時間",
|
||||
"Show as": "表示方法",
|
||||
"Availability on {date}": "{date} の空き状況",
|
||||
"Count all events as busy": "すべての予定を「予定あり」とする",
|
||||
"Only events I'm attending": "参加する予定のみ",
|
||||
"Don't include in availability": "空き状況に含めない",
|
||||
@@ -357,7 +496,6 @@ export const catalog: Catalog = {
|
||||
"New address book": "新しいアドレス帳",
|
||||
"No address books yet.": "アドレス帳がまだありません。",
|
||||
"Choose from address books": "アドレス帳から選択",
|
||||
"Import vCard": "vCard をインポート",
|
||||
"Export all contacts": "すべての連絡先をエクスポート",
|
||||
"Export address book": "このアドレス帳をエクスポート",
|
||||
"Import contacts…": "連絡先をインポート…",
|
||||
@@ -432,7 +570,6 @@ export const catalog: Catalog = {
|
||||
"Make ihasmail yours.": "ihasmail を自分好みに整えましょう。",
|
||||
"Reading": "閲覧",
|
||||
"Reading pane": "プレビューウィンドウ",
|
||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "閲覧・送信・一覧の動作。設定はこのブラウザーに保存されます。",
|
||||
"Right of the list": "一覧の右",
|
||||
"Below the list": "一覧の下",
|
||||
"Hidden (open full width)": "表示しない(全幅で開く)",
|
||||
@@ -475,10 +612,6 @@ export const catalog: Catalog = {
|
||||
"Time zone": "タイムゾーン",
|
||||
"Week starts on": "週の始まり",
|
||||
"Monday": "月曜日",
|
||||
"Tuesday": "火曜日",
|
||||
"Wednesday": "水曜日",
|
||||
"Thursday": "木曜日",
|
||||
"Friday": "金曜日",
|
||||
"Saturday": "土曜日",
|
||||
"Sunday": "日曜日",
|
||||
"12-hour clock (6:23 PM)": "12 時間制 (6:23 PM)",
|
||||
@@ -729,12 +862,10 @@ export const catalog: Catalog = {
|
||||
"Manage labels": "ラベルを管理",
|
||||
"Create “{name}”": "「{name}」を作成",
|
||||
"Type a name to create your first label.": "名前を入力すると、最初のラベルを作成できます。",
|
||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "ラベルはメールに保存される IMAP キーワードなので、他のクライアントにも同期されます。名前と色はこのブラウザーに保存されます。",
|
||||
"New label": "新しいラベル",
|
||||
"Delete label": "ラベルを削除",
|
||||
|
||||
// ── Attachments, dates, search prose ───────────────────────────────
|
||||
"PDF": "PDF",
|
||||
"Large attachments may be rejected by some servers": "大きな添付ファイルは、サーバーによっては拒否されることがあります",
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "画像は「ファイル」内(フォルダー「ihasmail」)に保存され、送信時にメールへ埋め込まれます。",
|
||||
"After": "以降",
|
||||
@@ -809,7 +940,6 @@ export const catalog: Catalog = {
|
||||
"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.": "ディレクトリに他のユーザーが見つからないため、新しく追加することはできません。すでに設定されている共有は下に表示され、解除はできます。",
|
||||
"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 以降が必要で、それより古いサーバーへのサインインは拒否されます。",
|
||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} は {site} の配色で、新しいアカウントの初期テーマです。ダークテーマなので、明暗が問われる場面ではダークとして扱われます。下のアクセントカラーはその上に重ねて適用されます。",
|
||||
"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 に関する情報をあえて含めていません。このビルドがサーバーに求めるものは、上の行に示されています。",
|
||||
|
||||
// ── Constant labels ────────────────────────────────────────────────
|
||||
@@ -1366,6 +1496,15 @@ export const catalog: Catalog = {
|
||||
"no address": "アドレスなし",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"{n} accounts use this domain. Move or delete them first.": { other: "{n} 件のアカウントがこのドメインを使用しています。先に移動または削除してください。" },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { other: "サーバーはこのドメイン宛てのメールを受け付けなくなり、{n} 個の DKIM 鍵も削除されます。元に戻すことはできません。" },
|
||||
"{n} domains": { other: "{n} 件のドメイン" },
|
||||
"{n} mailing lists": { other: "{n} 件のメーリングリスト" },
|
||||
"{n} DKIM keys": { other: "{n} 個の DKIM 鍵" },
|
||||
"{n} other items": { other: "その他 {n} 件" },
|
||||
// ── Administration ────────────────────────────────────────────────
|
||||
"{n} accounts": { other: "{n} 件のアカウント" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
"Delete {n} items": { other: "{n} 件を削除" },
|
||||
"Delete {n} items?": { other: "{n} 件を削除しますか?" },
|
||||
|
||||
+149
-10
@@ -43,6 +43,146 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"Domains": "Domeinen",
|
||||
"By hand": "Handmatig",
|
||||
"Signing": "Ondertekent",
|
||||
"Published, not signing yet": "Gepubliceerd, ondertekent nog niet",
|
||||
"Retiring": "Wordt uitgefaseerd",
|
||||
"Retired": "Uitgefaseerd",
|
||||
"This domain no longer exists. Someone may have removed it.": "Dit domein bestaat niet meer. Mogelijk heeft iemand het verwijderd.",
|
||||
"That doesn't look like a domain name, such as example.com.": "Dat lijkt niet op een domeinnaam, zoals example.com.",
|
||||
"Added {name}. Its DNS records are ready to copy.": "{name} toegevoegd. De DNS-records staan klaar om te kopiëren.",
|
||||
"Saved {name}": "{name} opgeslagen",
|
||||
"Add domain": "Domein toevoegen",
|
||||
"Added {date}": "Toegevoegd op {date}",
|
||||
"This domain is disabled on the server.": "Dit domein is op de server uitgeschakeld.",
|
||||
"Your role lets you view domains but not change them.": "Met uw rol kunt u domeinen bekijken, maar niet wijzigen.",
|
||||
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Nieuwe domeinen ondertekenen hun e-mail met DKIM-sleutels die de server aanmaakt en vervangt. De DNS-records verschijnen hier zodra het domein is toegevoegd.",
|
||||
"Other names": "Andere namen",
|
||||
"Delivery": "Bezorging",
|
||||
"Catch-all address": "Catch-alladres",
|
||||
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "E-mail aan een adres dat niemand op dit domein heeft, wordt hier bezorgd. Laat leeg om die e-mail te weigeren.",
|
||||
"Plus addressing": "Plus-adressering",
|
||||
"Set by a custom rule on the server.": "Ingesteld door een aangepaste regel op de server.",
|
||||
"Mail to name+anything@ is delivered to name@.": "E-mail aan naam+wat-dan-ook@ wordt bezorgd bij naam@.",
|
||||
"DNS records": "DNS-records",
|
||||
"Published automatically through {provider}.": "Automatisch gepubliceerd via {provider}.",
|
||||
"Published automatically by the server.": "Automatisch gepubliceerd door de server.",
|
||||
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Voeg deze toe waar de DNS van dit domein wordt beheerd. Tot die tijd wordt e-mail niet bezorgd of vertrouwd.",
|
||||
"Copy {type} record for {name}": "{type}-record voor {name} kopiëren",
|
||||
"Copy value": "Waarde kopiëren",
|
||||
"Copied the zone file": "Zonebestand gekopieerd",
|
||||
"Copy all as a zone file": "Alles als zonebestand kopiëren",
|
||||
"The server returned no records for this domain.": "De server gaf geen records voor dit domein.",
|
||||
"DKIM keys": "DKIM-sleutels",
|
||||
"The server creates and rotates these keys itself.": "De server maakt en vervangt deze sleutels zelf.",
|
||||
"These keys are managed by hand on the server.": "Deze sleutels worden op de server handmatig beheerd.",
|
||||
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Geen DKIM-sleutels: e-mail van dit domein wordt niet ondertekend en komt eerder in de spam terecht.",
|
||||
"Managed by the server": "Beheerd door de server",
|
||||
"Certificate": "Certificaat",
|
||||
"Another name for this domain": "Andere naam voor dit domein",
|
||||
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "E-mail aan hetzelfde adres onder een van deze namen komt in hetzelfde account. Wijzigingen gelden na opslaan.",
|
||||
"Its DKIM keys have to be removed first, and your role can't remove them.": "De DKIM-sleutels moeten eerst worden verwijderd, en uw rol mag dat niet.",
|
||||
"The server stops accepting mail for this domain.": "De server accepteert geen e-mail meer voor dit domein.",
|
||||
"Remove domain…": "Domein verwijderen…",
|
||||
"Remove {name}?": "{name} verwijderen?",
|
||||
"Removed {name}": "{name} verwijderd",
|
||||
"The server kept the domain: it is still used by {things}.": "De server heeft het domein behouden: het wordt nog gebruikt door {things}.",
|
||||
"Remove domain": "Domein verwijderen",
|
||||
"The server stops accepting mail for this domain. This can't be undone.": "De server accepteert geen e-mail meer voor dit domein. Dit kan niet ongedaan worden gemaakt.",
|
||||
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Waar uw adressen wonen, en de DNS-records waardoor e-mail aankomt en wordt vertrouwd.",
|
||||
"Search domains": "Domeinen zoeken",
|
||||
"No domains match": "Geen domeinen gevonden",
|
||||
"No domains yet": "Nog geen domeinen",
|
||||
"DKIM": "DKIM",
|
||||
"Tenant": "Tenant",
|
||||
"Disabled": "Uitgeschakeld",
|
||||
"also {names}": "ook {names}",
|
||||
"The server did not say whether the domain was created.": "De server heeft niet gemeld of het domein is aangemaakt.",
|
||||
// ── Administration: refusals ───────────────────────────────────
|
||||
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Dat is geen geldige domeinnaam. Gebruik een naam zoals example.com, met een echt topleveldomein.",
|
||||
"That isn't a valid email address. Use a full address, such as [email protected].": "Dat is geen geldig e-mailadres. Gebruik een volledig adres, zoals [email protected].",
|
||||
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Dat is geen geldig adres. Gebruik letters, cijfers, punten, koppeltekens of underscores vóór de @.",
|
||||
"That isn't a valid host name or IP address.": "Dat is geen geldige hostnaam of IP-adres.",
|
||||
"A required value was left empty.": "Een verplichte waarde is leeg gelaten.",
|
||||
"Administration is turned off on this installation.": "Beheer is uitgeschakeld in deze installatie.",
|
||||
"The mail server could not carry out the request ({code}).": "De mailserver kon het verzoek niet uitvoeren ({code}).",
|
||||
"You can't give an account permissions your own role doesn't have.": "U kunt een account geen rechten geven die uw eigen rol niet heeft.",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "Dit account logt in via een externe adreslijst, dus het wachtwoord kan hier niet worden ingesteld.",
|
||||
"The server's licence allows no more accounts.": "De licentie van de server staat geen extra accounts toe.",
|
||||
"That domain name is already in use on this server, as a domain or another domain's other name.": "Die domeinnaam is op deze server al in gebruik, als domein of als andere naam van een ander domein.",
|
||||
"Your organisation has reached the number of domains it is allowed.": "Uw organisatie heeft het toegestane aantal domeinen bereikt.",
|
||||
"That is more than the mail server accepts in one change.": "Dat is meer dan de mailserver in één wijziging accepteert.",
|
||||
"The mail server rejected one of the values. Check what you entered and try again.": "De mailserver heeft een van de waarden geweigerd. Controleer wat u hebt ingevuld en probeer het opnieuw.",
|
||||
"The mail server refused the change ({code}).": "De mailserver heeft de wijziging geweigerd ({code}).",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Alleen op een apparaat dat u als uw eigen apparaat hebt aangemerkt. Log opnieuw in met ‘Dit is mijn eigen apparaat’ aangevinkt.",
|
||||
"Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.",
|
||||
"Administration": "Beheer",
|
||||
"Directory": "Adreslijst",
|
||||
"User": "Gebruiker",
|
||||
"Administrator": "Beheerder",
|
||||
"Custom role": "Aangepaste rol",
|
||||
"New account": "Nieuw account",
|
||||
"The people who sign in to mail on the domains you manage.": "De mensen die op de domeinen die u beheert inloggen op hun e-mail.",
|
||||
"Search by name or address": "Zoeken op naam of adres",
|
||||
"Search accounts": "Accounts zoeken",
|
||||
"No accounts match": "Geen accounts gevonden",
|
||||
"No accounts yet": "Nog geen accounts",
|
||||
"Nothing on your domains matches “{query}”.": "Niets op uw domeinen komt overeen met ‘{query}’.",
|
||||
"Open {address}": "{address} openen",
|
||||
"{from}–{to} of {total}": "{from}–{to} van {total}",
|
||||
"Previous page": "Vorige pagina",
|
||||
"Next page": "Volgende pagina",
|
||||
"Storage": "Opslag",
|
||||
"Groups": "Groepen",
|
||||
"{used} · no limit": "{used} · geen limiet",
|
||||
"Profile": "Profiel",
|
||||
"Domain": "Domein",
|
||||
"No domains are available to create an account on.": "Er is geen domein beschikbaar om een account op aan te maken.",
|
||||
"Sign-in": "Inloggen",
|
||||
"Other addresses": "Andere adressen",
|
||||
"Not in any group": "Geen lid van een groep",
|
||||
"You can't change your own role.": "U kunt uw eigen rol niet wijzigen.",
|
||||
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Alleen rollen waarvan u de rechten zelf hebt, worden aangeboden. Bij een account binnen een tenant betekent Beheerder: beheerder van die tenant.",
|
||||
"Limit in GB": "Limiet in GB",
|
||||
"No limit": "Geen limiet",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "Dit account heeft rechten die het uwe niet heeft. U kunt het bekijken, maar niet wijzigen.",
|
||||
"Your role lets you view accounts but not change them.": "Met uw rol kunt u accounts bekijken, maar niet wijzigen.",
|
||||
"This account has permissions yours doesn't.": "Dit account heeft rechten die het uwe niet heeft.",
|
||||
"You can't delete the account you're signed in with.": "U kunt het account waarmee u bent ingelogd niet verwijderen.",
|
||||
"Create account": "Account aanmaken",
|
||||
"An account needs an address.": "Een account heeft een adres nodig.",
|
||||
"Created {address}": "{address} aangemaakt",
|
||||
"Saved {address}": "{address} opgeslagen",
|
||||
"Generate a password": "Wachtwoord genereren",
|
||||
"Pass it on some way other than email to this address.": "Geef het door op een andere manier dan per e-mail naar dit adres.",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "Dit account heeft geen wachtwoord. Mogelijk logt het in via een adreslijst of single sign-on.",
|
||||
"Set a new password…": "Nieuw wachtwoord instellen…",
|
||||
"{name} will be signed out of every app and device using the old password.": "{name} wordt uitgelogd in alle apps en op alle apparaten die het oude wachtwoord gebruiken.",
|
||||
"New password set for {address}": "Nieuw wachtwoord ingesteld voor {address}",
|
||||
"Set password": "Wachtwoord instellen",
|
||||
"Remove {address}": "{address} verwijderen",
|
||||
"New address": "Nieuw adres",
|
||||
"another name": "andere naam",
|
||||
"Mail to these addresses is delivered to this account. Changes apply when you save.": "E-mail aan deze adressen wordt in dit account afgeleverd. Wijzigingen gelden na opslaan.",
|
||||
"Deletes the mailbox and everything in it.": "Verwijdert de mailbox en alles erin.",
|
||||
"Delete account…": "Account verwijderen…",
|
||||
"Delete {address}?": "{address} verwijderen?",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Hiermee worden de e-mail, agenda’s, contacten en bestanden in dit account verwijderd. De server wist ze op de achtergrond en dit kan niet ongedaan worden gemaakt.",
|
||||
"Type {address} to confirm": "Typ {address} om te bevestigen",
|
||||
"Delete account": "Account verwijderen",
|
||||
"Deleted {address}": "{address} verwijderd",
|
||||
"The server did not say whether the account was created.": "De server heeft niet gemeld of het account is aangemaakt.",
|
||||
"The mail server refused this. Your role may not allow it.": "De mailserver heeft dit geweigerd. Uw rol staat het mogelijk niet toe.",
|
||||
"That address is already in use on this server, as an account, a list or an alias.": "Dat adres is op deze server al in gebruik, als account, lijst of alias.",
|
||||
"One of the chosen domain, role or group can't be used for this account.": "Het gekozen domein, de rol of de groep kan niet voor dit account worden gebruikt.",
|
||||
"Your organisation has reached the number of accounts it is allowed.": "Uw organisatie heeft het toegestane aantal accounts bereikt.",
|
||||
"Something still depends on this, so the server kept it.": "Er hangt nog iets van af, dus de server heeft het behouden.",
|
||||
"This account no longer exists. Someone may have deleted it.": "Dit account bestaat niet meer. Mogelijk heeft iemand het verwijderd.",
|
||||
"The password was not accepted: {reason}": "Het wachtwoord is niet geaccepteerd: {reason}",
|
||||
"The password was not accepted.": "Het wachtwoord is niet geaccepteerd.",
|
||||
"Go to folder…": "Ga naar map…",
|
||||
"Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.",
|
||||
"Export iCAL file": "iCAL-bestand exporteren",
|
||||
@@ -315,7 +455,6 @@ export const catalog: Catalog = {
|
||||
"Busy": "Bezet",
|
||||
"Free/busy": "Vrij/bezet",
|
||||
"Show as": "Weergeven als",
|
||||
"Availability on {date}": "Beschikbaarheid op {date}",
|
||||
"Count all events as busy": "Alle afspraken als bezet tellen",
|
||||
"Only events I'm attending": "Alleen afspraken waaraan ik deelneem",
|
||||
"Don't include in availability": "Niet meetellen voor beschikbaarheid",
|
||||
@@ -354,7 +493,6 @@ export const catalog: Catalog = {
|
||||
"New address book": "Nieuw adresboek",
|
||||
"No address books yet.": "Nog geen adresboeken.",
|
||||
"Choose from address books": "Kiezen uit adresboeken",
|
||||
"Import vCard": "vCard importeren",
|
||||
"Export all contacts": "Alle contacten exporteren",
|
||||
"Export address book": "Dit adresboek exporteren",
|
||||
"Import contacts…": "Contacten importeren…",
|
||||
@@ -429,7 +567,6 @@ export const catalog: Catalog = {
|
||||
"Make ihasmail yours.": "Maak ihasmail van uzelf.",
|
||||
"Reading": "Lezen",
|
||||
"Reading pane": "Leesvenster",
|
||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Gedrag bij lezen, verzenden en in de lijst. De instellingen worden in deze browser bewaard.",
|
||||
"Right of the list": "Rechts van de lijst",
|
||||
"Below the list": "Onder de lijst",
|
||||
"Hidden (open full width)": "Verborgen (op volle breedte openen)",
|
||||
@@ -472,10 +609,6 @@ export const catalog: Catalog = {
|
||||
"Time zone": "Tijdzone",
|
||||
"Week starts on": "Week begint op",
|
||||
"Monday": "Maandag",
|
||||
"Tuesday": "Dinsdag",
|
||||
"Wednesday": "Woensdag",
|
||||
"Thursday": "Donderdag",
|
||||
"Friday": "Vrijdag",
|
||||
"Saturday": "Zaterdag",
|
||||
"Sunday": "Zondag",
|
||||
"12-hour clock (6:23 PM)": "12-uursnotatie (6:23 PM)",
|
||||
@@ -721,10 +854,8 @@ export const catalog: Catalog = {
|
||||
"Manage labels": "Labels beheren",
|
||||
"Create “{name}”": "“{name}” maken",
|
||||
"Type a name to create your first label.": "Typ een naam om uw eerste label te maken.",
|
||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Labels zijn IMAP-trefwoorden die in uw berichten worden opgeslagen en dus met andere clients synchroniseren. Namen en kleuren blijven in deze browser.",
|
||||
"New label": "Nieuw label",
|
||||
"Delete label": "Label verwijderen",
|
||||
"PDF": "PDF",
|
||||
"Large attachments may be rejected by some servers": "Grote bijlagen worden door sommige servers geweigerd",
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Afbeeldingen worden opgeslagen in uw Bestanden (map “ihasmail”) en bij verzending ingesloten.",
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Bedankt voor uw bericht. Ik ben afwezig tot … en reageer zodra ik terug ben.",
|
||||
@@ -858,7 +989,6 @@ 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.",
|
||||
"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}.",
|
||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} is het kleurenpalet van {site}, en waarmee een nieuw account begint. Het is een donker thema en telt dus overal als donker waar dat uitmaakt; de accentkleur hieronder werkt er nog steeds bovenop.",
|
||||
"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.",
|
||||
|
||||
// ── Composer status, calendar title ────────────────────────────────
|
||||
@@ -1354,6 +1484,15 @@ export const catalog: Catalog = {
|
||||
"no address": "geen adres",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} account gebruikt dit domein. Verplaats of verwijder het eerst.", other: "{n} accounts gebruiken dit domein. Verplaats of verwijder ze eerst." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "De server accepteert geen e-mail meer voor dit domein en de {n} DKIM-sleutel wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "De server accepteert geen e-mail meer voor dit domein en de {n} DKIM-sleutels worden verwijderd. Dit kan niet ongedaan worden gemaakt." },
|
||||
"{n} domains": { one: "{n} domein", other: "{n} domeinen" },
|
||||
"{n} mailing lists": { one: "{n} mailinglijst", other: "{n} mailinglijsten" },
|
||||
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
|
||||
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
|
||||
// ── Administration ────────────────────────────────────────────────
|
||||
"{n} accounts": { one: "{n} account", other: "{n} accounts" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
"Delete {n} items": { one: "{n} item verwijderen", other: "{n} items verwijderen" },
|
||||
"Delete {n} items?": { one: "{n} item verwijderen?", other: "{n} items verwijderen?" },
|
||||
|
||||
+149
-10
@@ -50,6 +50,146 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"Domains": "Domínios",
|
||||
"By hand": "Manual",
|
||||
"Signing": "Assinando",
|
||||
"Published, not signing yet": "Publicada, ainda não assina",
|
||||
"Retiring": "Sendo retirada",
|
||||
"Retired": "Retirada",
|
||||
"This domain no longer exists. Someone may have removed it.": "Este domínio não existe mais. Talvez alguém o tenha removido.",
|
||||
"That doesn't look like a domain name, such as example.com.": "Isso não parece um nome de domínio, como example.com.",
|
||||
"Added {name}. Its DNS records are ready to copy.": "{name} adicionado. Os registros DNS estão prontos para copiar.",
|
||||
"Saved {name}": "{name} salvo",
|
||||
"Add domain": "Adicionar domínio",
|
||||
"Added {date}": "Adicionado em {date}",
|
||||
"This domain is disabled on the server.": "Este domínio está desativado no servidor.",
|
||||
"Your role lets you view domains but not change them.": "Sua função permite ver os domínios, mas não alterá-los.",
|
||||
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Domínios novos assinam os e-mails com chaves DKIM que o servidor cria e renova. Os registros DNS aparecem aqui depois que o domínio é adicionado.",
|
||||
"Other names": "Outros nomes",
|
||||
"Delivery": "Entrega",
|
||||
"Catch-all address": "Endereço pega-tudo",
|
||||
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "E-mails para um endereço que ninguém tem neste domínio são entregues aqui. Deixe vazio para recusá-los.",
|
||||
"Plus addressing": "Endereços com +",
|
||||
"Set by a custom rule on the server.": "Definido por uma regra personalizada no servidor.",
|
||||
"Mail to name+anything@ is delivered to name@.": "E-mails para nome+qualquercoisa@ são entregues a nome@.",
|
||||
"DNS records": "Registros DNS",
|
||||
"Published automatically through {provider}.": "Publicados automaticamente via {provider}.",
|
||||
"Published automatically by the server.": "Publicados automaticamente pelo servidor.",
|
||||
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Adicione-os onde o DNS deste domínio está hospedado. Os e-mails não são entregues nem considerados confiáveis até que estejam lá.",
|
||||
"Copy {type} record for {name}": "Copiar o registro {type} de {name}",
|
||||
"Copy value": "Copiar valor",
|
||||
"Copied the zone file": "Arquivo de zona copiado",
|
||||
"Copy all as a zone file": "Copiar tudo como arquivo de zona",
|
||||
"The server returned no records for this domain.": "O servidor não retornou registros para este domínio.",
|
||||
"DKIM keys": "Chaves DKIM",
|
||||
"The server creates and rotates these keys itself.": "O servidor cria e renova estas chaves sozinho.",
|
||||
"These keys are managed by hand on the server.": "Estas chaves são gerenciadas manualmente no servidor.",
|
||||
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Sem chaves DKIM: os e-mails deste domínio não são assinados e têm mais chance de ir para o spam.",
|
||||
"Managed by the server": "Gerenciado pelo servidor",
|
||||
"Certificate": "Certificado",
|
||||
"Another name for this domain": "Outro nome para este domínio",
|
||||
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "E-mails para o mesmo endereço em qualquer um destes nomes chegam à mesma conta. As alterações valem ao salvar.",
|
||||
"Its DKIM keys have to be removed first, and your role can't remove them.": "As chaves DKIM precisam ser removidas primeiro, e sua função não pode removê-las.",
|
||||
"The server stops accepting mail for this domain.": "O servidor deixa de aceitar e-mails para este domínio.",
|
||||
"Remove domain…": "Remover domínio…",
|
||||
"Remove {name}?": "Remover {name}?",
|
||||
"Removed {name}": "{name} removido",
|
||||
"The server kept the domain: it is still used by {things}.": "O servidor manteve o domínio: ele ainda é usado por {things}.",
|
||||
"Remove domain": "Remover domínio",
|
||||
"The server stops accepting mail for this domain. This can't be undone.": "O servidor deixa de aceitar e-mails para este domínio. Não é possível desfazer.",
|
||||
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Onde seus endereços ficam, e os registros DNS que fazem os e-mails chegarem e serem confiáveis.",
|
||||
"Search domains": "Pesquisar domínios",
|
||||
"No domains match": "Nenhum domínio corresponde",
|
||||
"No domains yet": "Ainda não há domínios",
|
||||
"DKIM": "DKIM",
|
||||
"Tenant": "Locatário",
|
||||
"Disabled": "Desativado",
|
||||
"also {names}": "também {names}",
|
||||
"The server did not say whether the domain was created.": "O servidor não informou se o domínio foi criado.",
|
||||
// ── Administration: refusals ───────────────────────────────────
|
||||
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Esse não é um nome de domínio válido. Use um nome como example.com, com um domínio de nível superior real.",
|
||||
"That isn't a valid email address. Use a full address, such as [email protected].": "Esse não é um endereço de e-mail válido. Use um endereço completo, como [email protected].",
|
||||
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Esse não é um endereço válido. Use letras, números, pontos, hifens ou sublinhados antes do @.",
|
||||
"That isn't a valid host name or IP address.": "Esse não é um nome de host ou endereço IP válido.",
|
||||
"A required value was left empty.": "Um valor obrigatório foi deixado em branco.",
|
||||
"Administration is turned off on this installation.": "A administração está desativada nesta instalação.",
|
||||
"The mail server could not carry out the request ({code}).": "O servidor de e-mail não conseguiu executar a solicitação ({code}).",
|
||||
"You can't give an account permissions your own role doesn't have.": "Você não pode dar a uma conta permissões que sua própria função não tem.",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "Esta conta entra por meio de um diretório externo, então a senha dela não pode ser definida aqui.",
|
||||
"The server's licence allows no more accounts.": "A licença do servidor não permite mais contas.",
|
||||
"That domain name is already in use on this server, as a domain or another domain's other name.": "Esse nome de domínio já está em uso neste servidor, como domínio ou como outro nome de outro domínio.",
|
||||
"Your organisation has reached the number of domains it is allowed.": "Sua organização atingiu o número de domínios permitido.",
|
||||
"That is more than the mail server accepts in one change.": "Isso é mais do que o servidor de e-mail aceita em uma única alteração.",
|
||||
"The mail server rejected one of the values. Check what you entered and try again.": "O servidor de e-mail recusou um dos valores. Confira o que você digitou e tente novamente.",
|
||||
"The mail server refused the change ({code}).": "O servidor de e-mail recusou a alteração ({code}).",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Só em um dispositivo que você marcou como seu. Entre novamente com “Este dispositivo é meu” marcado.",
|
||||
"Change your own password in {settings}.": "Altere sua própria senha em {settings}.",
|
||||
"Administration": "Administração",
|
||||
"Directory": "Diretório",
|
||||
"User": "Usuário",
|
||||
"Administrator": "Administrador",
|
||||
"Custom role": "Função personalizada",
|
||||
"New account": "Nova conta",
|
||||
"The people who sign in to mail on the domains you manage.": "As pessoas que entram no e-mail nos domínios que você administra.",
|
||||
"Search by name or address": "Pesquisar por nome ou endereço",
|
||||
"Search accounts": "Pesquisar contas",
|
||||
"No accounts match": "Nenhuma conta corresponde",
|
||||
"No accounts yet": "Ainda não há contas",
|
||||
"Nothing on your domains matches “{query}”.": "Nada nos seus domínios corresponde a “{query}”.",
|
||||
"Open {address}": "Abrir {address}",
|
||||
"{from}–{to} of {total}": "{from}–{to} de {total}",
|
||||
"Previous page": "Página anterior",
|
||||
"Next page": "Próxima página",
|
||||
"Storage": "Armazenamento",
|
||||
"Groups": "Grupos",
|
||||
"{used} · no limit": "{used} · sem limite",
|
||||
"Profile": "Perfil",
|
||||
"Domain": "Domínio",
|
||||
"No domains are available to create an account on.": "Não há nenhum domínio disponível para criar uma conta.",
|
||||
"Sign-in": "Acesso",
|
||||
"Other addresses": "Outros endereços",
|
||||
"Not in any group": "Não está em nenhum grupo",
|
||||
"You can't change your own role.": "Você não pode alterar sua própria função.",
|
||||
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Só são oferecidas as funções cujas permissões você mesmo tem. Em uma conta dentro de um locatário, Administrador significa administrador desse locatário.",
|
||||
"Limit in GB": "Limite em GB",
|
||||
"No limit": "Sem limite",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "Esta conta 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 accounts but not change them.": "Sua função permite ver as contas, mas não alterá-las.",
|
||||
"This account has permissions yours doesn't.": "Esta conta tem permissões que a sua não tem.",
|
||||
"You can't delete the account you're signed in with.": "Você não pode excluir a conta com a qual está conectado.",
|
||||
"Create account": "Criar conta",
|
||||
"An account needs an address.": "Uma conta precisa de um endereço.",
|
||||
"Created {address}": "{address} criada",
|
||||
"Saved {address}": "{address} salva",
|
||||
"Generate a password": "Gerar uma senha",
|
||||
"Pass it on some way other than email to this address.": "Repasse-a por outro meio que não seja um e-mail para este endereço.",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "Esta conta não tem senha. Ela pode entrar por meio de um diretório ou de login único.",
|
||||
"Set a new password…": "Definir nova senha…",
|
||||
"{name} will be signed out of every app and device using the old password.": "{name} será desconectado de todos os apps e dispositivos que usam a senha antiga.",
|
||||
"New password set for {address}": "Nova senha definida para {address}",
|
||||
"Set password": "Definir senha",
|
||||
"Remove {address}": "Remover {address}",
|
||||
"New address": "Novo endereço",
|
||||
"another name": "outro nome",
|
||||
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Os e-mails enviados a estes endereços são entregues nesta conta. As alterações valem ao salvar.",
|
||||
"Deletes the mailbox and everything in it.": "Exclui a caixa de correio e tudo o que há nela.",
|
||||
"Delete account…": "Excluir conta…",
|
||||
"Delete {address}?": "Excluir {address}?",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Isto exclui os e-mails, agendas, contatos e arquivos desta conta. O servidor os remove em segundo plano, e não é possível desfazer.",
|
||||
"Type {address} to confirm": "Digite {address} para confirmar",
|
||||
"Delete account": "Excluir conta",
|
||||
"Deleted {address}": "{address} excluída",
|
||||
"The server did not say whether the account was created.": "O servidor não informou se a conta foi criada.",
|
||||
"The mail server refused this. Your role may not allow it.": "O servidor de e-mail recusou. Talvez sua função não permita.",
|
||||
"That address is already in use on this server, as an account, a list or an alias.": "Esse endereço já está em uso neste servidor, como conta, lista ou alias.",
|
||||
"One of the chosen domain, role or group can't be used for this account.": "O domínio, a função ou o grupo escolhido não pode ser usado nesta conta.",
|
||||
"Your organisation has reached the number of accounts it is allowed.": "Sua organização atingiu o número de contas permitido.",
|
||||
"Something still depends on this, so the server kept it.": "Algo ainda depende disto, então o servidor o manteve.",
|
||||
"This account no longer exists. Someone may have deleted it.": "Esta conta não existe mais. Talvez alguém a tenha excluído.",
|
||||
"The password was not accepted: {reason}": "A senha não foi aceita: {reason}",
|
||||
"The password was not accepted.": "A senha não foi aceita.",
|
||||
"Go to folder…": "Ir para a pasta…",
|
||||
"Set for everyone here. You cannot change this.": "Definido para todos aqui. Você não pode alterar isto.",
|
||||
"Export iCAL file": "Exportar arquivo iCAL",
|
||||
@@ -322,7 +462,6 @@ export const catalog: Catalog = {
|
||||
"Busy": "Ocupado",
|
||||
"Free/busy": "Disponibilidade",
|
||||
"Show as": "Mostrar como",
|
||||
"Availability on {date}": "Disponibilidade em {date}",
|
||||
"Count all events as busy": "Contar todos os eventos como ocupado",
|
||||
"Only events I'm attending": "Somente os eventos de que participo",
|
||||
"Don't include in availability": "Não incluir na disponibilidade",
|
||||
@@ -361,7 +500,6 @@ export const catalog: Catalog = {
|
||||
"New address book": "Novo catálogo de endereços",
|
||||
"No address books yet.": "Ainda não há catálogos de endereços.",
|
||||
"Choose from address books": "Escolher nos catálogos de endereços",
|
||||
"Import vCard": "Importar um vCard",
|
||||
"Export all contacts": "Exportar todos os contatos",
|
||||
"Export address book": "Exportar este catálogo de endereços",
|
||||
"Import contacts…": "Importar contatos…",
|
||||
@@ -435,7 +573,6 @@ export const catalog: Catalog = {
|
||||
"Make ihasmail yours.": "Deixe o ihasmail do seu jeito.",
|
||||
"Reading": "Leitura",
|
||||
"Reading pane": "Painel de leitura",
|
||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Comportamento de leitura, envio e lista. As configurações ficam guardadas neste navegador.",
|
||||
"Right of the list": "À direita da lista",
|
||||
"Below the list": "Abaixo da lista",
|
||||
"Hidden (open full width)": "Oculto (abrir em largura total)",
|
||||
@@ -478,10 +615,6 @@ export const catalog: Catalog = {
|
||||
"Time zone": "Fuso horário",
|
||||
"Week starts on": "A semana começa em",
|
||||
"Monday": "Segunda-feira",
|
||||
"Tuesday": "Terça-feira",
|
||||
"Wednesday": "Quarta-feira",
|
||||
"Thursday": "Quinta-feira",
|
||||
"Friday": "Sexta-feira",
|
||||
"Saturday": "Sábado",
|
||||
"Sunday": "Domingo",
|
||||
"12-hour clock (6:23 PM)": "Formato de 12 horas (6:23 PM)",
|
||||
@@ -728,10 +861,8 @@ export const catalog: Catalog = {
|
||||
"Manage labels": "Gerenciar os marcadores",
|
||||
"Create “{name}”": "Criar “{name}”",
|
||||
"Type a name to create your first label.": "Digite um nome para criar seu primeiro marcador.",
|
||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Os marcadores são palavras-chave IMAP guardadas nas suas mensagens, então eles sincronizam com outros clientes. Os nomes e as cores ficam neste navegador.",
|
||||
"New label": "Novo marcador",
|
||||
"Delete label": "Excluir o marcador",
|
||||
"PDF": "PDF",
|
||||
"Large attachments may be rejected by some servers": "Anexos grandes podem ser recusados por alguns servidores",
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "As imagens são guardadas nos seus Arquivos (pasta “ihasmail”) e incorporadas no envio.",
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Obrigado pela sua mensagem. Estarei ausente até … e responderei quando voltar.",
|
||||
@@ -865,7 +996,6 @@ 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.",
|
||||
"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}.",
|
||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} é a paleta de {site}, e com a qual uma conta nova começa. É um tema escuro, então conta como escuro onde isso importa, e a cor de destaque abaixo continua valendo por cima dele.",
|
||||
"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.",
|
||||
|
||||
// ── Composer status, calendar title ────────────────────────────────
|
||||
@@ -1361,6 +1491,15 @@ export const catalog: Catalog = {
|
||||
"no address": "nenhum endereço",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} conta usa este domínio. Mova-a ou exclua-a primeiro.", other: "{n} contas usam este domínio. Mova-as ou exclua-as primeiro." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "O servidor deixa de aceitar e-mails para este domínio, e a {n} chave DKIM dele é excluída. Não é possível desfazer.", other: "O servidor deixa de aceitar e-mails para este domínio, e as {n} chaves DKIM dele são excluídas. Não é possível desfazer." },
|
||||
"{n} domains": { one: "{n} domínio", other: "{n} domínios" },
|
||||
"{n} mailing lists": { one: "{n} lista de e-mails", other: "{n} listas de e-mails" },
|
||||
"{n} DKIM keys": { one: "{n} chave DKIM", other: "{n} chaves DKIM" },
|
||||
"{n} other items": { one: "{n} outro item", other: "{n} outros itens" },
|
||||
// ── Administration ────────────────────────────────────────────────
|
||||
"{n} accounts": { one: "{n} conta", other: "{n} contas" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
"Delete {n} items": { one: "Excluir {n} item", other: "Excluir {n} itens" },
|
||||
"Delete {n} items?": { one: "Excluir {n} item?", other: "Excluir {n} itens?" },
|
||||
|
||||
+149
-10
@@ -49,6 +49,146 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"Domains": "Домены",
|
||||
"By hand": "Вручную",
|
||||
"Signing": "Подписывает",
|
||||
"Published, not signing yet": "Опубликован, ещё не подписывает",
|
||||
"Retiring": "Выводится из работы",
|
||||
"Retired": "Выведен из работы",
|
||||
"This domain no longer exists. Someone may have removed it.": "Этого домена больше нет. Возможно, его кто-то удалил.",
|
||||
"That doesn't look like a domain name, such as example.com.": "Это не похоже на имя домена вроде example.com.",
|
||||
"Added {name}. Its DNS records are ready to copy.": "Домен {name} добавлен. Его DNS-записи готовы к копированию.",
|
||||
"Saved {name}": "Домен {name} сохранён",
|
||||
"Add domain": "Добавить домен",
|
||||
"Added {date}": "Добавлен {date}",
|
||||
"This domain is disabled on the server.": "Этот домен отключён на сервере.",
|
||||
"Your role lets you view domains but not change them.": "Ваша роль позволяет просматривать домены, но не изменять их.",
|
||||
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Новые домены подписывают почту ключами DKIM, которые сервер создаёт и меняет сам. DNS-записи появятся здесь после добавления домена.",
|
||||
"Other names": "Другие имена",
|
||||
"Delivery": "Доставка",
|
||||
"Catch-all address": "Адрес для всей почты",
|
||||
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "Сюда доставляются письма на адреса этого домена, которых ни у кого нет. Оставьте пустым, чтобы отклонять такие письма.",
|
||||
"Plus addressing": "Адреса с «+»",
|
||||
"Set by a custom rule on the server.": "Задано особым правилом на сервере.",
|
||||
"Mail to name+anything@ is delivered to name@.": "Письма на имя+что-угодно@ доставляются на имя@.",
|
||||
"DNS records": "DNS-записи",
|
||||
"Published automatically through {provider}.": "Публикуются автоматически через {provider}.",
|
||||
"Published automatically by the server.": "Публикуются сервером автоматически.",
|
||||
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Добавьте их там, где размещён DNS этого домена. Пока их нет, почта не доставляется и не считается надёжной.",
|
||||
"Copy {type} record for {name}": "Скопировать запись {type} для {name}",
|
||||
"Copy value": "Скопировать значение",
|
||||
"Copied the zone file": "Файл зоны скопирован",
|
||||
"Copy all as a zone file": "Скопировать всё как файл зоны",
|
||||
"The server returned no records for this domain.": "Сервер не вернул записей для этого домена.",
|
||||
"DKIM keys": "Ключи DKIM",
|
||||
"The server creates and rotates these keys itself.": "Сервер создаёт и меняет эти ключи сам.",
|
||||
"These keys are managed by hand on the server.": "Эти ключи управляются на сервере вручную.",
|
||||
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Ключей DKIM нет, поэтому почта с этого домена не подписывается и чаще попадает в спам.",
|
||||
"Managed by the server": "Управляется сервером",
|
||||
"Certificate": "Сертификат",
|
||||
"Another name for this domain": "Другое имя для этого домена",
|
||||
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "Письма на тот же адрес под любым из этих имён попадают в ту же учётную запись. Изменения вступят в силу после сохранения.",
|
||||
"Its DKIM keys have to be removed first, and your role can't remove them.": "Сначала нужно удалить его ключи DKIM, а ваша роль этого не позволяет.",
|
||||
"The server stops accepting mail for this domain.": "Сервер перестанет принимать почту для этого домена.",
|
||||
"Remove domain…": "Удалить домен…",
|
||||
"Remove {name}?": "Удалить {name}?",
|
||||
"Removed {name}": "Домен {name} удалён",
|
||||
"The server kept the domain: it is still used by {things}.": "Сервер сохранил домен: его ещё используют {things}.",
|
||||
"Remove domain": "Удалить домен",
|
||||
"The server stops accepting mail for this domain. This can't be undone.": "Сервер перестанет принимать почту для этого домена. Отменить это нельзя.",
|
||||
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Где находятся ваши адреса и DNS-записи, благодаря которым почта доходит и вызывает доверие.",
|
||||
"Search domains": "Поиск доменов",
|
||||
"No domains match": "Нет подходящих доменов",
|
||||
"No domains yet": "Доменов пока нет",
|
||||
"DKIM": "DKIM",
|
||||
"Tenant": "Арендатор",
|
||||
"Disabled": "Отключён",
|
||||
"also {names}": "также {names}",
|
||||
"The server did not say whether the domain was created.": "Сервер не сообщил, создан ли домен.",
|
||||
// ── Administration: refusals ───────────────────────────────────
|
||||
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Это недопустимое имя домена. Укажите имя вроде example.com с настоящим доменом верхнего уровня.",
|
||||
"That isn't a valid email address. Use a full address, such as [email protected].": "Это недопустимый адрес электронной почты. Укажите полный адрес, например [email protected].",
|
||||
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Это недопустимый адрес. Перед @ используйте буквы, цифры, точки, дефисы или подчёркивания.",
|
||||
"That isn't a valid host name or IP address.": "Это недопустимое имя хоста или IP-адрес.",
|
||||
"A required value was left empty.": "Обязательное значение не заполнено.",
|
||||
"Administration is turned off on this installation.": "Администрирование отключено в этой установке.",
|
||||
"The mail server could not carry out the request ({code}).": "Почтовый сервер не смог выполнить запрос ({code}).",
|
||||
"You can't give an account permissions your own role doesn't have.": "Нельзя дать учётной записи разрешения, которых нет у вашей роли.",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "Эта учётная запись входит через внешний каталог, поэтому её пароль нельзя задать здесь.",
|
||||
"The server's licence allows no more accounts.": "Лицензия сервера не допускает новых учётных записей.",
|
||||
"That domain name is already in use on this server, as a domain or another domain's other name.": "Это имя домена уже используется на сервере — как домен или как другое имя другого домена.",
|
||||
"Your organisation has reached the number of domains it is allowed.": "Ваша организация достигла допустимого числа доменов.",
|
||||
"That is more than the mail server accepts in one change.": "Это больше, чем почтовый сервер принимает за одно изменение.",
|
||||
"The mail server rejected one of the values. Check what you entered and try again.": "Почтовый сервер отклонил одно из значений. Проверьте введённые данные и попробуйте снова.",
|
||||
"The mail server refused the change ({code}).": "Почтовый сервер отклонил изменение ({code}).",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Только на устройстве, отмеченном как ваше. Войдите снова, отметив «Это моё личное устройство».",
|
||||
"Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.",
|
||||
"Administration": "Администрирование",
|
||||
"Directory": "Каталог",
|
||||
"User": "Пользователь",
|
||||
"Administrator": "Администратор",
|
||||
"Custom role": "Особая роль",
|
||||
"New account": "Новая учётная запись",
|
||||
"The people who sign in to mail on the domains you manage.": "Люди, которые входят в почту на доменах, которыми вы управляете.",
|
||||
"Search by name or address": "Поиск по имени или адресу",
|
||||
"Search accounts": "Поиск учётных записей",
|
||||
"No accounts match": "Нет подходящих учётных записей",
|
||||
"No accounts yet": "Учётных записей пока нет",
|
||||
"Nothing on your domains matches “{query}”.": "На ваших доменах нет совпадений с «{query}».",
|
||||
"Open {address}": "Открыть {address}",
|
||||
"{from}–{to} of {total}": "{from}–{to} из {total}",
|
||||
"Previous page": "Предыдущая страница",
|
||||
"Next page": "Следующая страница",
|
||||
"Storage": "Хранилище",
|
||||
"Groups": "Группы",
|
||||
"{used} · no limit": "{used} · без ограничения",
|
||||
"Profile": "Профиль",
|
||||
"Domain": "Домен",
|
||||
"No domains are available to create an account on.": "Нет доменов, на которых можно создать учётную запись.",
|
||||
"Sign-in": "Вход",
|
||||
"Other addresses": "Другие адреса",
|
||||
"Not in any group": "Не состоит ни в одной группе",
|
||||
"You can't change your own role.": "Нельзя изменить собственную роль.",
|
||||
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Предлагаются только роли, все разрешения которых есть у вас самих. Для учётной записи внутри арендатора «Администратор» означает администратора этого арендатора.",
|
||||
"Limit in GB": "Ограничение в ГБ",
|
||||
"No limit": "Без ограничения",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "У этой учётной записи есть разрешения, которых нет у вашей, поэтому её можно просматривать, но не изменять.",
|
||||
"Your role lets you view accounts but not change them.": "Ваша роль позволяет просматривать учётные записи, но не изменять их.",
|
||||
"This account has permissions yours doesn't.": "У этой учётной записи есть разрешения, которых нет у вашей.",
|
||||
"You can't delete the account you're signed in with.": "Нельзя удалить учётную запись, под которой вы вошли.",
|
||||
"Create account": "Создать учётную запись",
|
||||
"An account needs an address.": "Учётной записи нужен адрес.",
|
||||
"Created {address}": "Учётная запись {address} создана",
|
||||
"Saved {address}": "Учётная запись {address} сохранена",
|
||||
"Generate a password": "Сгенерировать пароль",
|
||||
"Pass it on some way other than email to this address.": "Передайте его любым способом, кроме письма на этот адрес.",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "У этой учётной записи нет пароля. Возможно, вход выполняется через каталог или единый вход.",
|
||||
"Set a new password…": "Задать новый пароль…",
|
||||
"{name} will be signed out of every app and device using the old password.": "Для {name} будет выполнен выход во всех приложениях и на всех устройствах, где используется старый пароль.",
|
||||
"New password set for {address}": "Новый пароль для {address} задан",
|
||||
"Set password": "Задать пароль",
|
||||
"Remove {address}": "Удалить {address}",
|
||||
"New address": "Новый адрес",
|
||||
"another name": "другое имя",
|
||||
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Письма на эти адреса доставляются в эту учётную запись. Изменения вступят в силу после сохранения.",
|
||||
"Deletes the mailbox and everything in it.": "Удаляет почтовый ящик и всё его содержимое.",
|
||||
"Delete account…": "Удалить учётную запись…",
|
||||
"Delete {address}?": "Удалить {address}?",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Будут удалены почта, календари, контакты и файлы этой учётной записи. Сервер удалит их в фоновом режиме, отменить это нельзя.",
|
||||
"Type {address} to confirm": "Введите {address} для подтверждения",
|
||||
"Delete account": "Удалить учётную запись",
|
||||
"Deleted {address}": "Учётная запись {address} удалена",
|
||||
"The server did not say whether the account was created.": "Сервер не сообщил, создана ли учётная запись.",
|
||||
"The mail server refused this. Your role may not allow it.": "Почтовый сервер отклонил это действие. Возможно, ваша роль его не допускает.",
|
||||
"That address is already in use on this server, as an account, a list or an alias.": "Этот адрес уже используется на сервере — учётной записью, списком или псевдонимом.",
|
||||
"One of the chosen domain, role or group can't be used for this account.": "Выбранный домен, роль или группу нельзя использовать для этой учётной записи.",
|
||||
"Your organisation has reached the number of accounts it is allowed.": "Ваша организация достигла допустимого числа учётных записей.",
|
||||
"Something still depends on this, so the server kept it.": "От этого ещё что-то зависит, поэтому сервер это сохранил.",
|
||||
"This account no longer exists. Someone may have deleted it.": "Этой учётной записи больше нет. Возможно, её кто-то удалил.",
|
||||
"The password was not accepted: {reason}": "Пароль не принят: {reason}",
|
||||
"The password was not accepted.": "Пароль не принят.",
|
||||
"Go to folder…": "Перейти к папке…",
|
||||
"Set for everyone here. You cannot change this.": "Задано для всех здесь. Изменить нельзя.",
|
||||
"Export iCAL file": "Экспортировать файл iCAL",
|
||||
@@ -321,7 +461,6 @@ export const catalog: Catalog = {
|
||||
"Busy": "Занят",
|
||||
"Free/busy": "Занятость",
|
||||
"Show as": "Показывать как",
|
||||
"Availability on {date}": "Занятость на {date}",
|
||||
"Count all events as busy": "Считать все события занятостью",
|
||||
"Only events I'm attending": "Только события, где я участвую",
|
||||
"Don't include in availability": "Не учитывать в занятости",
|
||||
@@ -360,7 +499,6 @@ export const catalog: Catalog = {
|
||||
"New address book": "Новая адресная книга",
|
||||
"No address books yet.": "Адресных книг пока нет.",
|
||||
"Choose from address books": "Выбрать из адресных книг",
|
||||
"Import vCard": "Импорт vCard",
|
||||
"Export all contacts": "Экспортировать все контакты",
|
||||
"Export address book": "Экспортировать эту адресную книгу",
|
||||
"Import contacts…": "Импортировать контакты…",
|
||||
@@ -435,7 +573,6 @@ export const catalog: Catalog = {
|
||||
"Make ihasmail yours.": "Настройте ihasmail под себя.",
|
||||
"Reading": "Чтение",
|
||||
"Reading pane": "Область чтения",
|
||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Поведение при чтении, отправке и в списке. Настройки хранятся в этом браузере.",
|
||||
"Right of the list": "Справа от списка",
|
||||
"Below the list": "Под списком",
|
||||
"Hidden (open full width)": "Скрыта (открывать во всю ширину)",
|
||||
@@ -478,10 +615,6 @@ export const catalog: Catalog = {
|
||||
"Time zone": "Часовой пояс",
|
||||
"Week starts on": "Неделя начинается с",
|
||||
"Monday": "Понедельник",
|
||||
"Tuesday": "Вторник",
|
||||
"Wednesday": "Среда",
|
||||
"Thursday": "Четверг",
|
||||
"Friday": "Пятница",
|
||||
"Saturday": "Суббота",
|
||||
"Sunday": "Воскресенье",
|
||||
"12-hour clock (6:23 PM)": "12-часовой формат (6:23 PM)",
|
||||
@@ -727,10 +860,8 @@ export const catalog: Catalog = {
|
||||
"Manage labels": "Управление ярлыками",
|
||||
"Create “{name}”": "Создать «{name}»",
|
||||
"Type a name to create your first label.": "Введите название, чтобы создать первый ярлык.",
|
||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Ярлыки — это ключевые слова IMAP, которые хранятся в самих письмах и синхронизируются с другими клиентами. Названия и цвета остаются в этом браузере.",
|
||||
"New label": "Новый ярлык",
|
||||
"Delete label": "Удалить ярлык",
|
||||
"PDF": "PDF",
|
||||
"Large attachments may be rejected by some servers": "Некоторые серверы отклоняют большие вложения",
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Изображения хранятся в ваших Файлах (папка «ihasmail») и вставляются при отправке.",
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Спасибо за письмо. Я отсутствую до … и отвечу после возвращения.",
|
||||
@@ -864,7 +995,6 @@ 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 переведён, поэтому список растёт вместе с переводами, а не опережает их: язык без текстов заставил бы страницу утверждать, что она написана на языке, которым не является.",
|
||||
"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}.",
|
||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} — это палитра с {site}, с которой начинает новая учётная запись. Тема тёмная, поэтому везде, где это важно, считается тёмной, а акцентный цвет ниже применяется поверх неё.",
|
||||
"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; то, что этой сборке нужно от сервера, указано строкой выше.",
|
||||
|
||||
// ── Composer status, calendar title ────────────────────────────────
|
||||
@@ -1360,6 +1490,15 @@ export const catalog: Catalog = {
|
||||
"no address": "нет адреса",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "Этот домен использует {n} учётная запись. Сначала перенесите или удалите её.", few: "Этот домен используют {n} учётные записи. Сначала перенесите или удалите их.", many: "Этот домен используют {n} учётных записей. Сначала перенесите или удалите их.", other: "Этот домен используют {n} учётной записи. Сначала перенесите или удалите их." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Сервер перестанет принимать почту для этого домена, и его {n} ключ DKIM будет удалён. Отменить это нельзя.", few: "Сервер перестанет принимать почту для этого домена, и его {n} ключа DKIM будут удалены. Отменить это нельзя.", many: "Сервер перестанет принимать почту для этого домена, и его {n} ключей DKIM будут удалены. Отменить это нельзя.", other: "Сервер перестанет принимать почту для этого домена, и его {n} ключа DKIM будут удалены. Отменить это нельзя." },
|
||||
"{n} domains": { one: "{n} домен", few: "{n} домена", many: "{n} доменов", other: "{n} домена" },
|
||||
"{n} mailing lists": { one: "{n} список рассылки", few: "{n} списка рассылки", many: "{n} списков рассылки", other: "{n} списка рассылки" },
|
||||
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключа DKIM", many: "{n} ключей DKIM", other: "{n} ключа DKIM" },
|
||||
"{n} other items": { one: "{n} другой объект", few: "{n} других объекта", many: "{n} других объектов", other: "{n} другого объекта" },
|
||||
// ── Administration ────────────────────────────────────────────────
|
||||
"{n} accounts": { one: "{n} учётная запись", few: "{n} учётные записи", many: "{n} учётных записей", other: "{n} учётной записи" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
"Delete {n} items": { one: "Удалить {n} объект", few: "Удалить {n} объекта", many: "Удалить {n} объектов", other: "Удалить {n} объекта" },
|
||||
"Delete {n} items?": { one: "Удалить {n} объект?", few: "Удалить {n} объекта?", many: "Удалить {n} объектов?", other: "Удалить {n} объекта?" },
|
||||
|
||||
+149
-10
@@ -43,6 +43,146 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"Domains": "Домени",
|
||||
"By hand": "Вручну",
|
||||
"Signing": "Підписує",
|
||||
"Published, not signing yet": "Опубліковано, ще не підписує",
|
||||
"Retiring": "Виводиться з роботи",
|
||||
"Retired": "Виведено з роботи",
|
||||
"This domain no longer exists. Someone may have removed it.": "Цього домену більше немає. Можливо, його хтось видалив.",
|
||||
"That doesn't look like a domain name, such as example.com.": "Це не схоже на ім'я домену на кшталт example.com.",
|
||||
"Added {name}. Its DNS records are ready to copy.": "Домен {name} додано. Його DNS-записи готові до копіювання.",
|
||||
"Saved {name}": "Домен {name} збережено",
|
||||
"Add domain": "Додати домен",
|
||||
"Added {date}": "Додано {date}",
|
||||
"This domain is disabled on the server.": "Цей домен вимкнено на сервері.",
|
||||
"Your role lets you view domains but not change them.": "Ваша роль дозволяє переглядати домени, але не змінювати їх.",
|
||||
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Нові домени підписують пошту ключами DKIM, які сервер створює й змінює сам. DNS-записи з'являться тут після додавання домену.",
|
||||
"Other names": "Інші імена",
|
||||
"Delivery": "Доставлення",
|
||||
"Catch-all address": "Адреса для всієї пошти",
|
||||
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "Сюди доставляються листи на адреси цього домену, яких ні в кого немає. Залиште порожнім, щоб відхиляти такі листи.",
|
||||
"Plus addressing": "Адреси з «+»",
|
||||
"Set by a custom rule on the server.": "Задано власним правилом на сервері.",
|
||||
"Mail to name+anything@ is delivered to name@.": "Листи на ім'я+будь-що@ доставляються на ім'я@.",
|
||||
"DNS records": "DNS-записи",
|
||||
"Published automatically through {provider}.": "Публікуються автоматично через {provider}.",
|
||||
"Published automatically by the server.": "Публікуються сервером автоматично.",
|
||||
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Додайте їх там, де розміщено DNS цього домену. Поки їх немає, пошта не доставляється й не вважається надійною.",
|
||||
"Copy {type} record for {name}": "Скопіювати запис {type} для {name}",
|
||||
"Copy value": "Скопіювати значення",
|
||||
"Copied the zone file": "Файл зони скопійовано",
|
||||
"Copy all as a zone file": "Скопіювати все як файл зони",
|
||||
"The server returned no records for this domain.": "Сервер не повернув записів для цього домену.",
|
||||
"DKIM keys": "Ключі DKIM",
|
||||
"The server creates and rotates these keys itself.": "Сервер створює й змінює ці ключі сам.",
|
||||
"These keys are managed by hand on the server.": "Цими ключами керують на сервері вручну.",
|
||||
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Ключів DKIM немає, тому пошта з цього домену не підписується й частіше потрапляє до спаму.",
|
||||
"Managed by the server": "Керується сервером",
|
||||
"Certificate": "Сертифікат",
|
||||
"Another name for this domain": "Інше ім'я для цього домену",
|
||||
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "Листи на ту саму адресу під будь-яким із цих імен потрапляють до того самого облікового запису. Зміни наберуть чинності після збереження.",
|
||||
"Its DKIM keys have to be removed first, and your role can't remove them.": "Спершу треба видалити його ключі DKIM, а ваша роль цього не дозволяє.",
|
||||
"The server stops accepting mail for this domain.": "Сервер перестане приймати пошту для цього домену.",
|
||||
"Remove domain…": "Видалити домен…",
|
||||
"Remove {name}?": "Видалити {name}?",
|
||||
"Removed {name}": "Домен {name} видалено",
|
||||
"The server kept the domain: it is still used by {things}.": "Сервер зберіг домен: його ще використовують {things}.",
|
||||
"Remove domain": "Видалити домен",
|
||||
"The server stops accepting mail for this domain. This can't be undone.": "Сервер перестане приймати пошту для цього домену. Скасувати це неможливо.",
|
||||
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Де розміщено ваші адреси та DNS-записи, завдяки яким пошта доходить і викликає довіру.",
|
||||
"Search domains": "Пошук доменів",
|
||||
"No domains match": "Немає відповідних доменів",
|
||||
"No domains yet": "Доменів ще немає",
|
||||
"DKIM": "DKIM",
|
||||
"Tenant": "Орендар",
|
||||
"Disabled": "Вимкнено",
|
||||
"also {names}": "також {names}",
|
||||
"The server did not say whether the domain was created.": "Сервер не повідомив, чи створено домен.",
|
||||
// ── Administration: refusals ───────────────────────────────────
|
||||
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Це недійсне ім'я домену. Вкажіть ім'я на кшталт example.com зі справжнім доменом верхнього рівня.",
|
||||
"That isn't a valid email address. Use a full address, such as [email protected].": "Це недійсна адреса електронної пошти. Вкажіть повну адресу, наприклад [email protected].",
|
||||
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Це недійсна адреса. Перед @ використовуйте літери, цифри, крапки, дефіси або підкреслення.",
|
||||
"That isn't a valid host name or IP address.": "Це недійсне ім'я хоста чи IP-адреса.",
|
||||
"A required value was left empty.": "Обов'язкове значення не заповнено.",
|
||||
"Administration is turned off on this installation.": "Адміністрування вимкнено в цьому встановленні.",
|
||||
"The mail server could not carry out the request ({code}).": "Поштовий сервер не зміг виконати запит ({code}).",
|
||||
"You can't give an account permissions your own role doesn't have.": "Не можна надати обліковому запису дозволи, яких немає у вашої ролі.",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "Цей обліковий запис входить через зовнішній каталог, тому його пароль не можна задати тут.",
|
||||
"The server's licence allows no more accounts.": "Ліцензія сервера не дозволяє нових облікових записів.",
|
||||
"That domain name is already in use on this server, as a domain or another domain's other name.": "Це ім'я домену вже використовується на сервері — як домен або як інше ім'я іншого домену.",
|
||||
"Your organisation has reached the number of domains it is allowed.": "Ваша організація досягла дозволеної кількості доменів.",
|
||||
"That is more than the mail server accepts in one change.": "Це більше, ніж поштовий сервер приймає за одну зміну.",
|
||||
"The mail server rejected one of the values. Check what you entered and try again.": "Поштовий сервер відхилив одне зі значень. Перевірте введені дані й спробуйте ще раз.",
|
||||
"The mail server refused the change ({code}).": "Поштовий сервер відхилив зміну ({code}).",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Лише на пристрої, позначеному як ваш. Увійдіть знову, позначивши «Це мій власний пристрій».",
|
||||
"Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.",
|
||||
"Administration": "Адміністрування",
|
||||
"Directory": "Каталог",
|
||||
"User": "Користувач",
|
||||
"Administrator": "Адміністратор",
|
||||
"Custom role": "Власна роль",
|
||||
"New account": "Новий обліковий запис",
|
||||
"The people who sign in to mail on the domains you manage.": "Люди, які входять у пошту на доменах, якими ви керуєте.",
|
||||
"Search by name or address": "Пошук за іменем або адресою",
|
||||
"Search accounts": "Пошук облікових записів",
|
||||
"No accounts match": "Немає відповідних облікових записів",
|
||||
"No accounts yet": "Облікових записів ще немає",
|
||||
"Nothing on your domains matches “{query}”.": "На ваших доменах немає збігів із «{query}».",
|
||||
"Open {address}": "Відкрити {address}",
|
||||
"{from}–{to} of {total}": "{from}–{to} із {total}",
|
||||
"Previous page": "Попередня сторінка",
|
||||
"Next page": "Наступна сторінка",
|
||||
"Storage": "Сховище",
|
||||
"Groups": "Групи",
|
||||
"{used} · no limit": "{used} · без обмеження",
|
||||
"Profile": "Профіль",
|
||||
"Domain": "Домен",
|
||||
"No domains are available to create an account on.": "Немає доменів, на яких можна створити обліковий запис.",
|
||||
"Sign-in": "Вхід",
|
||||
"Other addresses": "Інші адреси",
|
||||
"Not in any group": "Не входить до жодної групи",
|
||||
"You can't change your own role.": "Ви не можете змінити власну роль.",
|
||||
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Пропонуються лише ролі, усі дозволи яких маєте ви самі. Для облікового запису всередині орендаря «Адміністратор» означає адміністратора цього орендаря.",
|
||||
"Limit in GB": "Обмеження в ГБ",
|
||||
"No limit": "Без обмеження",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "Цей обліковий запис має дозволи, яких немає у вашого, тому його можна переглядати, але не змінювати.",
|
||||
"Your role lets you view accounts but not change them.": "Ваша роль дозволяє переглядати облікові записи, але не змінювати їх.",
|
||||
"This account has permissions yours doesn't.": "Цей обліковий запис має дозволи, яких немає у вашого.",
|
||||
"You can't delete the account you're signed in with.": "Не можна видалити обліковий запис, під яким ви ввійшли.",
|
||||
"Create account": "Створити обліковий запис",
|
||||
"An account needs an address.": "Обліковому запису потрібна адреса.",
|
||||
"Created {address}": "Обліковий запис {address} створено",
|
||||
"Saved {address}": "Обліковий запис {address} збережено",
|
||||
"Generate a password": "Згенерувати пароль",
|
||||
"Pass it on some way other than email to this address.": "Передайте його будь-яким способом, окрім листа на цю адресу.",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "Цей обліковий запис не має пароля. Можливо, вхід виконується через каталог або єдиний вхід.",
|
||||
"Set a new password…": "Задати новий пароль…",
|
||||
"{name} will be signed out of every app and device using the old password.": "Для {name} буде виконано вихід у всіх застосунках і на всіх пристроях, де використовується старий пароль.",
|
||||
"New password set for {address}": "Новий пароль для {address} задано",
|
||||
"Set password": "Задати пароль",
|
||||
"Remove {address}": "Видалити {address}",
|
||||
"New address": "Нова адреса",
|
||||
"another name": "інше ім'я",
|
||||
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Листи на ці адреси доставляються в цей обліковий запис. Зміни наберуть чинності після збереження.",
|
||||
"Deletes the mailbox and everything in it.": "Видаляє поштову скриньку й усе, що в ній.",
|
||||
"Delete account…": "Видалити обліковий запис…",
|
||||
"Delete {address}?": "Видалити {address}?",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Буде видалено пошту, календарі, контакти й файли цього облікового запису. Сервер видалить їх у фоновому режимі, скасувати це неможливо.",
|
||||
"Type {address} to confirm": "Введіть {address} для підтвердження",
|
||||
"Delete account": "Видалити обліковий запис",
|
||||
"Deleted {address}": "Обліковий запис {address} видалено",
|
||||
"The server did not say whether the account was created.": "Сервер не повідомив, чи створено обліковий запис.",
|
||||
"The mail server refused this. Your role may not allow it.": "Поштовий сервер відхилив цю дію. Можливо, ваша роль її не дозволяє.",
|
||||
"That address is already in use on this server, as an account, a list or an alias.": "Ця адреса вже використовується на сервері — обліковим записом, списком або псевдонімом.",
|
||||
"One of the chosen domain, role or group can't be used for this account.": "Вибраний домен, роль або групу не можна використати для цього облікового запису.",
|
||||
"Your organisation has reached the number of accounts it is allowed.": "Ваша організація досягла дозволеної кількості облікових записів.",
|
||||
"Something still depends on this, so the server kept it.": "Від цього ще щось залежить, тому сервер це зберіг.",
|
||||
"This account no longer exists. Someone may have deleted it.": "Цього облікового запису більше немає. Можливо, його хтось видалив.",
|
||||
"The password was not accepted: {reason}": "Пароль не прийнято: {reason}",
|
||||
"The password was not accepted.": "Пароль не прийнято.",
|
||||
"Go to folder…": "Перейти до теки…",
|
||||
"Set for everyone here. You cannot change this.": "Задано для всіх тут. Змінити не можна.",
|
||||
"Export iCAL file": "Експортувати файл iCAL",
|
||||
@@ -315,7 +455,6 @@ export const catalog: Catalog = {
|
||||
"Busy": "Зайнятий",
|
||||
"Free/busy": "Зайнятість",
|
||||
"Show as": "Показувати як",
|
||||
"Availability on {date}": "Зайнятість на {date}",
|
||||
"Count all events as busy": "Вважати всі події зайнятістю",
|
||||
"Only events I'm attending": "Лише події, де я беру участь",
|
||||
"Don't include in availability": "Не враховувати в зайнятості",
|
||||
@@ -354,7 +493,6 @@ export const catalog: Catalog = {
|
||||
"New address book": "Нова адресна книга",
|
||||
"No address books yet.": "Адресних книг поки немає.",
|
||||
"Choose from address books": "Вибрати з адресних книг",
|
||||
"Import vCard": "Імпорт vCard",
|
||||
"Export all contacts": "Експортувати всі контакти",
|
||||
"Export address book": "Експортувати цю адресну книгу",
|
||||
"Import contacts…": "Імпортувати контакти…",
|
||||
@@ -429,7 +567,6 @@ export const catalog: Catalog = {
|
||||
"Make ihasmail yours.": "Налаштуйте ihasmail під себе.",
|
||||
"Reading": "Читання",
|
||||
"Reading pane": "Область читання",
|
||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Поведінка під час читання, надсилання та в списку. Налаштування зберігаються в цьому браузері.",
|
||||
"Right of the list": "Праворуч від списку",
|
||||
"Below the list": "Під списком",
|
||||
"Hidden (open full width)": "Прихована (відкривати на всю ширину)",
|
||||
@@ -472,10 +609,6 @@ export const catalog: Catalog = {
|
||||
"Time zone": "Часовий пояс",
|
||||
"Week starts on": "Тиждень починається з",
|
||||
"Monday": "Понеділок",
|
||||
"Tuesday": "Вівторок",
|
||||
"Wednesday": "Середа",
|
||||
"Thursday": "Четвер",
|
||||
"Friday": "П'ятниця",
|
||||
"Saturday": "Субота",
|
||||
"Sunday": "Неділя",
|
||||
"12-hour clock (6:23 PM)": "12-годинний формат (6:23 PM)",
|
||||
@@ -721,10 +854,8 @@ export const catalog: Catalog = {
|
||||
"Manage labels": "Керування мітками",
|
||||
"Create “{name}”": "Створити «{name}»",
|
||||
"Type a name to create your first label.": "Введіть назву, щоб створити першу мітку.",
|
||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Мітки — це ключові слова IMAP, які зберігаються в самих листах і синхронізуються з іншими клієнтами. Назви та кольори залишаються в цьому браузері.",
|
||||
"New label": "Нова мітка",
|
||||
"Delete label": "Видалити мітку",
|
||||
"PDF": "PDF",
|
||||
"Large attachments may be rejected by some servers": "Деякі сервери відхиляють великі вкладення",
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Зображення зберігаються у ваших Файлах (тека «ihasmail») і вставляються під час надсилання.",
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Дякую за лист. Мене немає до … і я відповім після повернення.",
|
||||
@@ -858,7 +989,6 @@ 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, тому список зростає разом із перекладами, а не випереджає їх: мова без текстів змусила б сторінку стверджувати, що вона написана мовою, якою не є.",
|
||||
"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}.",
|
||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} — це палітра з {site}, з якою починає новий обліковий запис. Тема темна, тому скрізь, де це важливо, вважається темною, а акцентний колір нижче застосовується поверх неї.",
|
||||
"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; те, що цій збірці потрібно від сервера, вказано рядком вище.",
|
||||
|
||||
// ── Composer status, calendar title ────────────────────────────────
|
||||
@@ -1354,6 +1484,15 @@ export const catalog: Catalog = {
|
||||
"no address": "немає адреси",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "Цей домен використовує {n} обліковий запис. Спершу перенесіть або видаліть його.", few: "Цей домен використовують {n} облікові записи. Спершу перенесіть або видаліть їх.", many: "Цей домен використовують {n} облікових записів. Спершу перенесіть або видаліть їх.", other: "Цей домен використовують {n} облікового запису. Спершу перенесіть або видаліть їх." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Сервер перестане приймати пошту для цього домену, і його {n} ключ DKIM буде видалено. Скасувати це неможливо.", few: "Сервер перестане приймати пошту для цього домену, і його {n} ключі DKIM буде видалено. Скасувати це неможливо.", many: "Сервер перестане приймати пошту для цього домену, і його {n} ключів DKIM буде видалено. Скасувати це неможливо.", other: "Сервер перестане приймати пошту для цього домену, і його {n} ключа DKIM буде видалено. Скасувати це неможливо." },
|
||||
"{n} domains": { one: "{n} домен", few: "{n} домени", many: "{n} доменів", other: "{n} домену" },
|
||||
"{n} mailing lists": { one: "{n} список розсилки", few: "{n} списки розсилки", many: "{n} списків розсилки", other: "{n} списку розсилки" },
|
||||
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключі DKIM", many: "{n} ключів DKIM", other: "{n} ключа DKIM" },
|
||||
"{n} other items": { one: "{n} інший об'єкт", few: "{n} інші об'єкти", many: "{n} інших об'єктів", other: "{n} іншого об'єкта" },
|
||||
// ── Administration ────────────────────────────────────────────────
|
||||
"{n} accounts": { one: "{n} обліковий запис", few: "{n} облікові записи", many: "{n} облікових записів", other: "{n} облікового запису" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
"Delete {n} items": { one: "Видалити {n} об’єкт", few: "Видалити {n} об’єкти", many: "Видалити {n} об’єктів", other: "Видалити {n} об’єкта" },
|
||||
"Delete {n} items?": { one: "Видалити {n} об’єкт?", few: "Видалити {n} об’єкти?", many: "Видалити {n} об’єктів?", other: "Видалити {n} об’єкта?" },
|
||||
|
||||
+149
-10
@@ -45,6 +45,146 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"Domains": "域名",
|
||||
"By hand": "手动",
|
||||
"Signing": "签名中",
|
||||
"Published, not signing yet": "已发布,尚未签名",
|
||||
"Retiring": "正在停用",
|
||||
"Retired": "已停用",
|
||||
"This domain no longer exists. Someone may have removed it.": "该域名已不存在,可能已被他人移除。",
|
||||
"That doesn't look like a domain name, such as example.com.": "这看起来不像 example.com 这样的域名。",
|
||||
"Added {name}. Its DNS records are ready to copy.": "已添加 {name},其 DNS 记录可以复制了。",
|
||||
"Saved {name}": "已保存 {name}",
|
||||
"Add domain": "添加域名",
|
||||
"Added {date}": "添加于 {date}",
|
||||
"This domain is disabled on the server.": "该域名已在服务器上停用。",
|
||||
"Your role lets you view domains but not change them.": "您的角色可以查看域名,但不能更改。",
|
||||
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "新域名使用服务器创建并轮换的 DKIM 密钥为邮件签名。添加域名后,其 DNS 记录会显示在这里。",
|
||||
"Other names": "其他名称",
|
||||
"Delivery": "投递",
|
||||
"Catch-all address": "全收地址",
|
||||
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "发往此域名下不存在地址的邮件会投递到这里。留空则拒收此类邮件。",
|
||||
"Plus addressing": "加号地址",
|
||||
"Set by a custom rule on the server.": "由服务器上的自定义规则设定。",
|
||||
"Mail to name+anything@ is delivered to name@.": "发往 名称+任意内容@ 的邮件会投递到 名称@。",
|
||||
"DNS records": "DNS 记录",
|
||||
"Published automatically through {provider}.": "通过 {provider} 自动发布。",
|
||||
"Published automatically by the server.": "由服务器自动发布。",
|
||||
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "请将这些记录添加到托管此域名 DNS 的地方。在此之前,邮件无法投递,也不会被信任。",
|
||||
"Copy {type} record for {name}": "复制 {name} 的 {type} 记录",
|
||||
"Copy value": "复制值",
|
||||
"Copied the zone file": "已复制区域文件",
|
||||
"Copy all as a zone file": "全部复制为区域文件",
|
||||
"The server returned no records for this domain.": "服务器没有返回此域名的记录。",
|
||||
"DKIM keys": "DKIM 密钥",
|
||||
"The server creates and rotates these keys itself.": "服务器会自行创建并轮换这些密钥。",
|
||||
"These keys are managed by hand on the server.": "这些密钥在服务器上手动管理。",
|
||||
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "没有 DKIM 密钥,因此此域名的邮件不会被签名,更容易被判为垃圾邮件。",
|
||||
"Managed by the server": "由服务器管理",
|
||||
"Certificate": "证书",
|
||||
"Another name for this domain": "此域名的其他名称",
|
||||
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "发往这些名称下相同地址的邮件会到达同一个账户。更改在保存后生效。",
|
||||
"Its DKIM keys have to be removed first, and your role can't remove them.": "需要先移除其 DKIM 密钥,而您的角色无权移除。",
|
||||
"The server stops accepting mail for this domain.": "服务器将不再接收此域名的邮件。",
|
||||
"Remove domain…": "移除域名…",
|
||||
"Remove {name}?": "移除 {name}?",
|
||||
"Removed {name}": "已移除 {name}",
|
||||
"The server kept the domain: it is still used by {things}.": "服务器保留了该域名:它仍被 {things} 使用。",
|
||||
"Remove domain": "移除域名",
|
||||
"The server stops accepting mail for this domain. This can't be undone.": "服务器将不再接收此域名的邮件。此操作无法撤销。",
|
||||
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "您的地址所在之处,以及让邮件送达并获得信任的 DNS 记录。",
|
||||
"Search domains": "搜索域名",
|
||||
"No domains match": "没有匹配的域名",
|
||||
"No domains yet": "暂无域名",
|
||||
"DKIM": "DKIM",
|
||||
"Tenant": "租户",
|
||||
"Disabled": "已停用",
|
||||
"also {names}": "别名:{names}",
|
||||
"The server did not say whether the domain was created.": "服务器没有说明域名是否已创建。",
|
||||
// ── Administration: refusals ───────────────────────────────────
|
||||
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "这不是有效的域名。请使用 example.com 这样、带有真实顶级域名的名称。",
|
||||
"That isn't a valid email address. Use a full address, such as [email protected].": "这不是有效的电子邮件地址。请使用完整地址,例如 [email protected]。",
|
||||
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "这不是有效的地址。@ 前请使用字母、数字、点、连字符或下划线。",
|
||||
"That isn't a valid host name or IP address.": "这不是有效的主机名或 IP 地址。",
|
||||
"A required value was left empty.": "有必填值未填写。",
|
||||
"Administration is turned off on this installation.": "此安装已关闭管理功能。",
|
||||
"The mail server could not carry out the request ({code}).": "邮件服务器无法执行该请求({code})。",
|
||||
"You can't give an account permissions your own role doesn't have.": "您不能授予账户您自己的角色所没有的权限。",
|
||||
"This account signs in through an external directory, so its password can't be set here.": "该账户通过外部目录登录,因此无法在此设置其密码。",
|
||||
"The server's licence allows no more accounts.": "服务器许可证不允许再添加账户。",
|
||||
"That domain name is already in use on this server, as a domain or another domain's other name.": "该域名已在此服务器上被使用,可能是一个域名,也可能是另一个域名的其他名称。",
|
||||
"Your organisation has reached the number of domains it is allowed.": "您的组织已达到允许的域名数量上限。",
|
||||
"That is more than the mail server accepts in one change.": "这超出了邮件服务器单次更改可接受的范围。",
|
||||
"The mail server rejected one of the values. Check what you entered and try again.": "邮件服务器拒绝了其中一个值。请检查您输入的内容后重试。",
|
||||
"The mail server refused the change ({code}).": "邮件服务器拒绝了此更改({code})。",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "仅限在您标记为自己设备的设备上使用。请勾选「这是我自己的设备」后重新登录。",
|
||||
"Change your own password in {settings}.": "请在{settings}中更改您自己的密码。",
|
||||
"Administration": "管理",
|
||||
"Directory": "目录",
|
||||
"User": "用户",
|
||||
"Administrator": "管理员",
|
||||
"Custom role": "自定义角色",
|
||||
"New account": "新建账户",
|
||||
"The people who sign in to mail on the domains you manage.": "在您管理的域名上登录邮箱的人员。",
|
||||
"Search by name or address": "按姓名或地址搜索",
|
||||
"Search accounts": "搜索账户",
|
||||
"No accounts match": "没有匹配的账户",
|
||||
"No accounts yet": "暂无账户",
|
||||
"Nothing on your domains matches “{query}”.": "您的域名中没有与「{query}」匹配的内容。",
|
||||
"Open {address}": "打开 {address}",
|
||||
"{from}–{to} of {total}": "第 {from}–{to} 个,共 {total} 个",
|
||||
"Previous page": "上一页",
|
||||
"Next page": "下一页",
|
||||
"Storage": "存储",
|
||||
"Groups": "群组",
|
||||
"{used} · no limit": "{used} · 无限制",
|
||||
"Profile": "资料",
|
||||
"Domain": "域名",
|
||||
"No domains are available to create an account on.": "没有可用于创建账户的域名。",
|
||||
"Sign-in": "登录",
|
||||
"Other addresses": "其他地址",
|
||||
"Not in any group": "不属于任何群组",
|
||||
"You can't change your own role.": "您无法更改自己的角色。",
|
||||
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "仅提供您本人拥有其全部权限的角色。对于租户内的账户,管理员指的是该租户的管理员。",
|
||||
"Limit in GB": "限额(GB)",
|
||||
"No limit": "无限制",
|
||||
"This account has permissions yours doesn't, so you can view it but not change it.": "该账户拥有您的账户所没有的权限,因此您只能查看,无法更改。",
|
||||
"Your role lets you view accounts but not change them.": "您的角色可以查看账户,但不能更改。",
|
||||
"This account has permissions yours doesn't.": "该账户拥有您的账户所没有的权限。",
|
||||
"You can't delete the account you're signed in with.": "您无法删除当前登录的账户。",
|
||||
"Create account": "创建账户",
|
||||
"An account needs an address.": "账户需要一个地址。",
|
||||
"Created {address}": "已创建 {address}",
|
||||
"Saved {address}": "已保存 {address}",
|
||||
"Generate a password": "生成密码",
|
||||
"Pass it on some way other than email to this address.": "请通过发往此地址的邮件以外的方式转交。",
|
||||
"This account has no password. It may sign in through a directory or single sign-on.": "该账户没有密码,可能通过目录服务或单点登录进行登录。",
|
||||
"Set a new password…": "设置新密码…",
|
||||
"{name} will be signed out of every app and device using the old password.": "{name} 将在所有使用旧密码的应用和设备上被退出登录。",
|
||||
"New password set for {address}": "已为 {address} 设置新密码",
|
||||
"Set password": "设置密码",
|
||||
"Remove {address}": "移除 {address}",
|
||||
"New address": "新地址",
|
||||
"another name": "其他名称",
|
||||
"Mail to these addresses is delivered to this account. Changes apply when you save.": "发往这些地址的邮件会投递到此账户。更改在保存后生效。",
|
||||
"Deletes the mailbox and everything in it.": "删除邮箱及其中的全部内容。",
|
||||
"Delete account…": "删除账户…",
|
||||
"Delete {address}?": "删除 {address}?",
|
||||
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "这将删除此账户中的邮件、日历、联系人和文件。服务器会在后台移除它们,且无法撤销。",
|
||||
"Type {address} to confirm": "输入 {address} 以确认",
|
||||
"Delete account": "删除账户",
|
||||
"Deleted {address}": "已删除 {address}",
|
||||
"The server did not say whether the account was created.": "服务器没有说明账户是否已创建。",
|
||||
"The mail server refused this. Your role may not allow it.": "邮件服务器拒绝了此操作。您的角色可能不允许。",
|
||||
"That address is already in use on this server, as an account, a list or an alias.": "该地址已在此服务器上被账户、列表或别名使用。",
|
||||
"One of the chosen domain, role or group can't be used for this account.": "所选的域名、角色或群组无法用于此账户。",
|
||||
"Your organisation has reached the number of accounts it is allowed.": "您的组织已达到允许的账户数量上限。",
|
||||
"Something still depends on this, so the server kept it.": "仍有其他内容依赖于它,因此服务器保留了它。",
|
||||
"This account no longer exists. Someone may have deleted it.": "该账户已不存在,可能已被他人删除。",
|
||||
"The password was not accepted: {reason}": "密码未被接受:{reason}",
|
||||
"The password was not accepted.": "密码未被接受。",
|
||||
"Go to folder…": "转到文件夹…",
|
||||
"Set for everyone here. You cannot change this.": "已为此处所有人设定,您无法更改。",
|
||||
"Export iCAL file": "导出 iCAL 文件",
|
||||
@@ -317,7 +457,6 @@ export const catalog: Catalog = {
|
||||
"Busy": "忙碌",
|
||||
"Free/busy": "忙闲状态",
|
||||
"Show as": "显示为",
|
||||
"Availability on {date}": "{date} 的忙闲状态",
|
||||
"Count all events as busy": "所有日程都计为忙碌",
|
||||
"Only events I'm attending": "仅我参加的日程",
|
||||
"Don't include in availability": "不计入忙闲状态",
|
||||
@@ -356,7 +495,6 @@ export const catalog: Catalog = {
|
||||
"New address book": "新建通讯录",
|
||||
"No address books yet.": "还没有通讯录。",
|
||||
"Choose from address books": "从通讯录中选择",
|
||||
"Import vCard": "导入 vCard",
|
||||
"Export all contacts": "导出所有联系人",
|
||||
"Export address book": "导出此通讯录",
|
||||
"Import contacts…": "导入联系人…",
|
||||
@@ -431,7 +569,6 @@ export const catalog: Catalog = {
|
||||
"Make ihasmail yours.": "把 ihasmail 调成您喜欢的样子。",
|
||||
"Reading": "阅读",
|
||||
"Reading pane": "阅读窗格",
|
||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "阅读、发送和列表行为。设置保存在此浏览器中。",
|
||||
"Right of the list": "列表右侧",
|
||||
"Below the list": "列表下方",
|
||||
"Hidden (open full width)": "隐藏(全宽打开)",
|
||||
@@ -474,10 +611,6 @@ export const catalog: Catalog = {
|
||||
"Time zone": "时区",
|
||||
"Week starts on": "每周开始于",
|
||||
"Monday": "星期一",
|
||||
"Tuesday": "星期二",
|
||||
"Wednesday": "星期三",
|
||||
"Thursday": "星期四",
|
||||
"Friday": "星期五",
|
||||
"Saturday": "星期六",
|
||||
"Sunday": "星期日",
|
||||
"12-hour clock (6:23 PM)": "12 小时制 (6:23 PM)",
|
||||
@@ -728,12 +861,10 @@ export const catalog: Catalog = {
|
||||
"Manage labels": "管理标签",
|
||||
"Create “{name}”": "创建「{name}」",
|
||||
"Type a name to create your first label.": "输入名称以创建您的第一个标签。",
|
||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "标签是保存在邮件上的 IMAP 关键词,因此会同步到其他客户端。名称和颜色则保存在此浏览器中。",
|
||||
"New label": "新建标签",
|
||||
"Delete label": "删除标签",
|
||||
|
||||
// ── Attachments, dates, search prose ───────────────────────────────
|
||||
"PDF": "PDF",
|
||||
"Large attachments may be rejected by some servers": "部分服务器可能拒收过大的附件",
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "图片保存在您的「文件」中(文件夹「ihasmail」),并在发送时嵌入邮件。",
|
||||
"After": "晚于",
|
||||
@@ -808,7 +939,6 @@ export const catalog: Catalog = {
|
||||
"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.": "目录中没有找到其他用户,因此无法添加新的共享对象。已有的共享列在下方,仍可移除。",
|
||||
"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 或更高版本,更旧的版本一律无法登录。",
|
||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} 是 {site} 的配色,也是新账户的初始主题。它属于深色主题,因此在需要区分明暗的地方都算作深色,下方的强调色仍会叠加在它之上。",
|
||||
"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 的信息;此版本对服务器的要求见上一行。",
|
||||
|
||||
// ── Constant labels ────────────────────────────────────────────────
|
||||
@@ -1365,6 +1495,15 @@ export const catalog: Catalog = {
|
||||
"no address": "无地址",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: domains ────────────────────────────────────
|
||||
"{n} accounts use this domain. Move or delete them first.": { other: "有 {n} 个账户正在使用此域名。请先移动或删除它们。" },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { other: "服务器将不再接收此域名的邮件,其 {n} 个 DKIM 密钥也会被删除。此操作无法撤销。" },
|
||||
"{n} domains": { other: "{n} 个域名" },
|
||||
"{n} mailing lists": { other: "{n} 个邮件列表" },
|
||||
"{n} DKIM keys": { other: "{n} 个 DKIM 密钥" },
|
||||
"{n} other items": { other: "其他 {n} 项" },
|
||||
// ── Administration ────────────────────────────────────────────────
|
||||
"{n} accounts": { other: "{n} 个账户" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
"Delete {n} items": { other: "删除 {n} 个项目" },
|
||||
"Delete {n} items?": { other: "要删除 {n} 个项目吗?" },
|
||||
|
||||
+77
-1
@@ -1547,7 +1547,13 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.composer-dock { position: fixed; right: 16px; bottom: 0; display: flex; align-items: flex-end; gap: 12px; z-index: 955; pointer-events: none; }
|
||||
.composer { pointer-events: auto; width: 580px; max-width: calc(100vw - 32px); height: 600px; max-height: calc(100vh - 24px); display: flex; flex-direction: column; background: var(--bg-elev); border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow-3); border: 1px solid var(--border); border-bottom: 0; overflow: hidden; animation: rise .2s var(--ease); }
|
||||
.composer.minimized { height: 44px; width: 280px; }
|
||||
.composer.maximized { position: fixed; inset: 24px; width: auto; height: auto; max-width: none; max-height: none; border-radius: var(--radius-lg); border-bottom: 1px solid var(--border); }
|
||||
/* Full screen is still a child of the dock, so without a layer of its own the
|
||||
positioned parts of any composer after it in the DOM (its recipients row,
|
||||
its editor) would paint straight over it. The others are hidden while one
|
||||
is full screen: they cannot be reached anyway, and the strip left under the
|
||||
24px inset would otherwise show their footers. */
|
||||
.composer.maximized { position: fixed; inset: 24px; z-index: 1; width: auto; height: auto; max-width: none; max-height: none; border-radius: var(--radius-lg); border-bottom: 1px solid var(--border); }
|
||||
.composer-dock.has-maximized > .composer:not(.maximized) { display: none; }
|
||||
.composer-head { display: flex; align-items: center; gap: 4px; height: 44px; padding: 0 6px 0 14px; background: var(--bg-sunken); border-bottom: 1px solid var(--border); flex: 0 0 auto; cursor: default; }
|
||||
.composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; }
|
||||
@@ -1667,6 +1673,74 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.sessions-table th, .sessions-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.sessions-table th { color: var(--fg-muted); font-weight: 600; font-size: .85em; }
|
||||
|
||||
/* ==========================================================================
|
||||
Administration
|
||||
Settings' layout, with a table for the list and a panel beside it for the
|
||||
one that is open. The panel is positioned against the layout rather than the
|
||||
scrolling content, so it stays put while the list scrolls under it.
|
||||
========================================================================== */
|
||||
/* One column: the section list is in the folder pane (AdminNav), so the
|
||||
table gets the width a second column would take. The height and scrolling
|
||||
are what .settings-layout gave it. */
|
||||
.admin-layout { position: relative; height: 100%; min-height: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.admin-content { max-width: 1120px; flex: 1; min-height: 0; }
|
||||
.admin-head { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
|
||||
.admin-head .grow { min-width: 220px; }
|
||||
.admin-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
|
||||
.admin-search { position: relative; flex: 1; max-width: 360px; }
|
||||
.admin-search svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--fg-faint); pointer-events: none; }
|
||||
.admin-search .input { width: 100%; padding-left: 34px; }
|
||||
.admin-table-wrap { border: 1px solid var(--border); border-radius: var(--radius); overflow-x: auto; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: .93em; }
|
||||
.admin-table th { text-align: left; padding: 9px 12px; font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--fg-faint); background: var(--bg-sunken); border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
.admin-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.admin-table tbody tr { cursor: pointer; }
|
||||
.admin-table tbody tr:hover { background: var(--bg-hover); }
|
||||
.admin-table tbody tr.selected { background: var(--bg-active); }
|
||||
.admin-table tbody tr:focus-visible { outline: none; box-shadow: inset var(--focus-ring); }
|
||||
.admin-who { display: flex; align-items: center; gap: 10px; min-width: 200px; }
|
||||
.admin-who-name { font-weight: 550; display: flex; align-items: center; gap: 6px; }
|
||||
.admin-groups { display: block; max-width: 180px; }
|
||||
.admin-role { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; font-size: .85em; font-weight: 550; white-space: nowrap; background: var(--bg-sunken); color: var(--fg-muted); }
|
||||
.admin-role.admin { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||
.admin-role.custom { background: transparent; border: 1px solid var(--border-strong); }
|
||||
.admin-meter { min-width: 120px; }
|
||||
.admin-meter .quota-bar { margin: 0 0 4px; }
|
||||
.admin-pager { display: flex; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 8px; }
|
||||
.admin-pager .hint { margin-right: 8px; font-variant-numeric: tabular-nums; }
|
||||
.admin-count { margin-top: 8px; }
|
||||
.admin-notice { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--bg-sunken); color: var(--fg-muted); font-size: .92em; margin: 8px 0; }
|
||||
.admin-notice svg { flex: none; margin-top: 2px; }
|
||||
.admin-notice.warn { background: var(--warn-soft); color: var(--fg); }
|
||||
.admin-notice.warn svg { color: var(--warn); }
|
||||
.admin-notice.error { background: var(--danger-soft); color: var(--fg); }
|
||||
.admin-sheet { position: absolute; top: 0; right: 0; bottom: 0; width: min(460px, 100%); z-index: 20; display: flex; flex-direction: column; background: var(--bg-elev); border-left: 1px solid var(--border); box-shadow: var(--shadow-3); animation: admin-sheet-in .18s var(--ease); }
|
||||
@keyframes admin-sheet-in { from { transform: translateX(24px); opacity: 0; } }
|
||||
.admin-sheet-head { display: flex; align-items: center; gap: 12px; padding: 14px 12px 12px 20px; border-bottom: 1px solid var(--border); }
|
||||
.admin-sheet-head h2 { margin: 0; padding: 0; border: 0; font-size: 1.1em; font-weight: 650; }
|
||||
.admin-sheet-body { flex: 1; overflow-y: auto; padding: 4px 20px 24px; }
|
||||
.admin-sheet-body h3 { margin: 22px 0 10px; font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-faint); }
|
||||
.admin-sheet-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); }
|
||||
.admin-address .input:first-child { flex: 0 1 160px; min-width: 0; }
|
||||
.admin-address select.input { flex: 1; min-width: 0; }
|
||||
.admin-wide { width: 100%; }
|
||||
.admin-narrow { max-width: 140px; }
|
||||
.admin-danger { border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); border-radius: var(--radius); padding: 12px 14px; }
|
||||
.admin-danger p { margin: 0 0 10px; color: var(--fg-muted); font-size: .92em; }
|
||||
.admin-danger-btn { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
|
||||
.admin-danger-btn:hover:not(:disabled) { background: var(--danger-soft); }
|
||||
.admin-dns { border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.admin-dns-row { display: flex; gap: 10px; align-items: flex-start; padding: 8px 8px 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.admin-dns-row:last-child { border-bottom: 0; }
|
||||
.admin-dns-type { flex: 0 0 48px; font-family: var(--font-mono); font-size: .78em; font-weight: 700; color: var(--accent-soft-fg); padding-top: 2px; }
|
||||
.admin-dns-name { font-size: .88em; font-weight: 550; word-break: break-all; }
|
||||
.admin-dns-value { display: block; font-family: var(--font-mono); font-size: .8em; color: var(--fg-muted); word-break: break-all; margin-top: 2px; max-height: 4.8em; overflow: hidden; }
|
||||
.admin-kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; align-items: center; margin: 0; font-size: .92em; }
|
||||
.admin-kv dt { color: var(--fg-muted); }
|
||||
.admin-kv dd { margin: 0; }
|
||||
@media (prefers-reduced-motion: reduce) { .admin-sheet { animation: none; } }
|
||||
|
||||
/* ==========================================================================
|
||||
Contacts
|
||||
========================================================================== */
|
||||
@@ -1950,6 +2024,8 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.settings-layout.root .settings-nav { display: block; border-right: 0; }
|
||||
.settings-layout.root .settings-content { display: none; }
|
||||
.settings-content { --pad-b: 80px; padding: 16px 16px var(--pad-b); }
|
||||
.admin-table .hide-mobile { display: none; }
|
||||
.admin-sheet { width: 100%; border-left: 0; box-shadow: none; }
|
||||
.contacts-layout { grid-template-columns: 1fr; }
|
||||
.contacts-books { display: none; }
|
||||
.contacts-layout.detail .contacts-list { display: none; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users, X } from "lucide-react";
|
||||
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||
@@ -21,6 +21,9 @@ import { formatSize } from "@/lib/format";
|
||||
import { collectShare } from "@/lib/shareTarget";
|
||||
import { TranslateBoundary } from "@/ui/TranslateBoundary";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { hasAdministration } from "@/lib/adminAccess";
|
||||
import { usePermissions } from "./admin/usePermissions";
|
||||
import { AdminNav } from "./admin/AdminNav";
|
||||
|
||||
const PUSH_LABEL = {
|
||||
connected: "Live updates connected",
|
||||
@@ -42,6 +45,8 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const logout = useSession((s) => s.logout);
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
const acctMenu = useMenu();
|
||||
const administers = hasAdministration(usePermissions());
|
||||
const needsOwnDevice = useSession((s) => Boolean(s.session?.ihasmail?.administrationNeedsOwnDevice));
|
||||
/*
|
||||
* "Go to folder" (#233), hosted here rather than in the mail view because
|
||||
* the `g` shortcuts are global: pressing it from the calendar should still
|
||||
@@ -156,6 +161,24 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
app there was no way back to it. */}
|
||||
<MenuItem icon={<Globe size={16} />} label={t("About ihasmail")} href="https://ihasmail.org" external />
|
||||
<MenuItem icon={<Settings size={16} />} label={t("Settings")} onClick={() => navigate("/settings")} />
|
||||
{/* Only for an account whose Stalwart role manages other accounts.
|
||||
Nobody else is shown an entry that would open onto refusals. */}
|
||||
{administers && <MenuItem icon={<ShieldCheck size={16} />} label={t("Administration")} active={section === "admin"} onClick={() => navigate("/admin")} />}
|
||||
{/* An administrator who signed in without "This is my own device". The
|
||||
server withholds administration from that session, so the entry is
|
||||
shown dead with the reason, rather than gone without one. */}
|
||||
{!administers && needsOwnDevice && (
|
||||
<MenuItem
|
||||
icon={<ShieldCheck size={16} />}
|
||||
disabled
|
||||
label={
|
||||
<>
|
||||
<span style={{ display: "block" }}>{t("Administration")}</span>
|
||||
<span className="hint" style={{ display: "block", whiteSpace: "normal" }}>{t("Only on a device you've marked as your own. Sign in again with “This is my own device” ticked.")}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
|
||||
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
|
||||
</Popover>
|
||||
@@ -207,6 +230,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{section === "contacts" && <ContactsSidebar />}
|
||||
{section === "files" && <FilesTree />}
|
||||
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
|
||||
{section === "admin" && <AdminNav />}
|
||||
</div>
|
||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||
<nav className="module-bar" aria-label={t("Go to")}>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Copy, Dices, KeyRound, Lock, Plus, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
ADMIN_BASELINE,
|
||||
can,
|
||||
canGrantRole,
|
||||
generatePassword,
|
||||
outranks,
|
||||
type UserRoles,
|
||||
} from "@/lib/adminAccess";
|
||||
import {
|
||||
aliasList,
|
||||
createAccount,
|
||||
describeDirectoryError,
|
||||
destroyAccount,
|
||||
hasPassword,
|
||||
passwordPatch,
|
||||
quotasWithDisk,
|
||||
updateAccount,
|
||||
DISK_QUOTA,
|
||||
type DirectoryAccount,
|
||||
type EmailAlias,
|
||||
} from "@/lib/adminDirectory";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { t, tNode } from "@/lib/i18n";
|
||||
import { Link } from "wouter";
|
||||
import { Avatar } from "@/ui/misc";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
interface Props {
|
||||
/** Null to create one. */
|
||||
account: DirectoryAccount | null;
|
||||
ctx: DirectoryContext;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
onCreated: (id: string) => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
/** A role as one select value: "User", "Admin", or "custom:<ids>". */
|
||||
function roleKey(roles: UserRoles | undefined): string {
|
||||
if (!roles || roles["@type"] === "User") return "User";
|
||||
if (roles["@type"] === "Admin") return "Admin";
|
||||
return `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`;
|
||||
}
|
||||
|
||||
function rolesFromKey(key: string): UserRoles {
|
||||
if (key === "Admin") return { "@type": "Admin" };
|
||||
if (key.startsWith("custom:")) {
|
||||
return { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) };
|
||||
}
|
||||
return { "@type": "User" };
|
||||
}
|
||||
|
||||
const gibOf = (bytes: number | undefined) => (bytes ? String(Math.round((bytes / GIB) * 10) / 10) : "");
|
||||
const bytesOf = (gib: string) => {
|
||||
const n = Number(gib.replace(",", "."));
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n * GIB) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One account, opened beside the list.
|
||||
*
|
||||
* A panel rather than a dialog, so the list stays visible and the next account
|
||||
* is one click away. Saving sends one `x:Account/set` with only what changed;
|
||||
* a password and a delete are their own calls, because each is a decision of
|
||||
* its own and should never ride along with a renamed display name.
|
||||
*/
|
||||
export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDeleted }: Props) {
|
||||
const perms = usePermissions();
|
||||
const creating = account === null;
|
||||
const self = account ? isSelf(account, ctx) : false;
|
||||
const locked = account ? outranks(perms, account, ctx.roles) : false;
|
||||
const editable = creating ? can(perms, "Account", "Create") : can(perms, "Account", "Update") && !locked;
|
||||
|
||||
const [description, setDescription] = useState(account?.description ?? "");
|
||||
const [name, setName] = useState("");
|
||||
const [domainId, setDomainId] = useState(ctx.domains[0]?.id ?? "");
|
||||
const [password, setPassword] = useState(() => (creating ? generatePassword() : ""));
|
||||
const [role, setRole] = useState(roleKey(account?.roles));
|
||||
const [quota, setQuota] = useState(gibOf(account?.quotas?.[DISK_QUOTA]));
|
||||
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(account?.aliases ?? {}));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
|
||||
}, [ctx.domains, domainId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
|
||||
const address = account?.emailAddress ?? `${name}@${domainName(domainId)}`;
|
||||
|
||||
const roleOptions = useMemo(() => {
|
||||
const options: { value: string; label: string }[] = [{ value: "User", label: t("User") }];
|
||||
if (ADMIN_BASELINE.every((p) => perms.has(p)) || role === "Admin") options.push({ value: "Admin", label: t("Administrator") });
|
||||
for (const r of ctx.roles?.values() ?? []) {
|
||||
if (canGrantRole(perms, r.id, ctx.roles)) options.push({ value: `custom:${r.id}`, label: r.description || r.id });
|
||||
}
|
||||
if (!options.some((o) => o.value === role)) options.push({ value: role, label: account ? roleName(account, ctx.roles) : role });
|
||||
return options;
|
||||
}, [perms, ctx.roles, role, account]);
|
||||
|
||||
const run = async (work: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () =>
|
||||
run(async () => {
|
||||
if (!account) {
|
||||
if (!name.trim() || !domainId) {
|
||||
setError(t("An account needs an address."));
|
||||
return;
|
||||
}
|
||||
const id = await createAccount({ name, domainId, description, password, roles: rolesFromKey(role), diskQuotaBytes: bytesOf(quota) });
|
||||
toast.success(t("Created {address}", { address }));
|
||||
onCreated(id);
|
||||
return;
|
||||
}
|
||||
const patch: Record<string, unknown> = {};
|
||||
if ((account.description ?? "") !== description) patch.description = description.trim() || null;
|
||||
if (roleKey(account.roles) !== role) patch.roles = rolesFromKey(role);
|
||||
if ((account.quotas?.[DISK_QUOTA] ?? null) !== bytesOf(quota)) patch.quotas = quotasWithDisk(account.quotas, bytesOf(quota));
|
||||
const before = JSON.stringify(aliasList(Object.values(account.aliases ?? {})));
|
||||
if (before !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
|
||||
if (!Object.keys(patch).length) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await updateAccount(account.id, patch);
|
||||
toast.success(t("Saved {address}", { address }));
|
||||
onChanged();
|
||||
});
|
||||
|
||||
const used = account?.usedDiskQuota ?? 0;
|
||||
const limit = account?.quotas?.[DISK_QUOTA];
|
||||
|
||||
return (
|
||||
<aside className="admin-sheet" aria-label={creating ? t("New account") : address}>
|
||||
<div className="admin-sheet-head">
|
||||
{account && <Avatar who={{ name: account.description || account.name, email: account.emailAddress }} />}
|
||||
<div className="grow">
|
||||
<h2 className="truncate">{creating ? t("New account") : account.description || account.name}</h2>
|
||||
{account && <div className="hint truncate notranslate" translate="no">{account.emailAddress}</div>}
|
||||
</div>
|
||||
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-sheet-body">
|
||||
{locked && (
|
||||
<p className="admin-notice warn">
|
||||
<Lock size={16} aria-hidden="true" />
|
||||
<span>{t("This account has permissions yours doesn't, so you can view it but not change it.")}</span>
|
||||
</p>
|
||||
)}
|
||||
{!creating && !locked && !can(perms, "Account", "Update") && (
|
||||
<p className="admin-notice">{t("Your role lets you view accounts but not change them.")}</p>
|
||||
)}
|
||||
|
||||
<h3>{t("Profile")}</h3>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-description">{t("Display name")}</label>
|
||||
<input id="admin-description" className="input" value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
{creating && (
|
||||
<div className="field">
|
||||
<label htmlFor="admin-name">{t("Address")}</label>
|
||||
<div className="row admin-address">
|
||||
<input id="admin-name" className="input" value={name} autoComplete="off" spellCheck={false} onChange={(e) => setName(e.target.value.trim().toLowerCase())} />
|
||||
<span className="muted">@</span>
|
||||
<select className="input" aria-label={t("Domain")} value={domainId} onChange={(e) => setDomainId(e.target.value)}>
|
||||
{ctx.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!ctx.domains.length && <span className="hint">{t("No domains are available to create an account on.")}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3>{t("Sign-in")}</h3>
|
||||
{creating ? (
|
||||
<PasswordField value={password} onChange={setPassword} />
|
||||
) : (
|
||||
self ? (
|
||||
// This session signs in with the password; changing it here would
|
||||
// strand it. Settings re-seals the session as it changes, so that is
|
||||
// the door for one's own.
|
||||
<p className="hint" style={{ marginTop: 0 }}>
|
||||
{tNode("Change your own password in {settings}.", { settings: <Link href="/settings/security">{t("Security & sessions")}</Link> })}
|
||||
</p>
|
||||
) : (
|
||||
<PasswordReset account={account} disabled={!editable} onDone={onChanged} />
|
||||
)
|
||||
)}
|
||||
|
||||
{!creating && (
|
||||
<>
|
||||
<h3>{t("Other addresses")}</h3>
|
||||
<Aliases aliases={aliases} setAliases={setAliases} editable={editable} domains={ctx.domains} defaultDomain={account.domainId} domainName={domainName} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!creating && (
|
||||
<>
|
||||
<h3>{t("Groups")}</h3>
|
||||
<div className="row wrap gap-4">
|
||||
{Object.keys(account.memberGroupIds ?? {}).length ? (
|
||||
Object.keys(account.memberGroupIds ?? {}).map((id) => {
|
||||
const g = ctx.groups.get(id);
|
||||
return <span key={id} className="chip">{g ? g.description || g.name : id}</span>;
|
||||
})
|
||||
) : (
|
||||
<span className="hint">{t("Not in any group")}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3>{t("Role")}</h3>
|
||||
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable || self} onChange={(e) => setRole(e.target.value)}>
|
||||
{roleOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<p className="hint">
|
||||
{self ? t("You can't change your own role.") : t("Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.")}
|
||||
</p>
|
||||
|
||||
<h3>{t("Storage")}</h3>
|
||||
{!creating && (
|
||||
<p className="hint" style={{ marginTop: 0 }}>
|
||||
{limit ? t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) }) : t("{used} · no limit", { used: formatSize(used) })}
|
||||
</p>
|
||||
)}
|
||||
<div className="field">
|
||||
<label htmlFor="admin-quota">{t("Limit in GB")}</label>
|
||||
<input id="admin-quota" className="input admin-narrow" inputMode="decimal" value={quota} disabled={!editable} placeholder={t("No limit")} onChange={(e) => setQuota(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{!creating && can(perms, "Account", "Destroy") && (
|
||||
<DeleteAccount account={account} blocked={self ? t("You can't delete the account you're signed in with.") : locked ? t("This account has permissions yours doesn't.") : null} onDeleted={onDeleted} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<div className="admin-sheet-foot">
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
|
||||
<button className="btn btn-primary" disabled={busy || (creating && (!name || !domainId || !password))} onClick={() => void save()}>
|
||||
{creating ? t("Create account") : t("Save changes")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordField({ value, onChange, id = "admin-password" }: { value: string; onChange: (v: string) => void; id?: string }) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label htmlFor={id}>{t("Password")}</label>
|
||||
<div className="row">
|
||||
<input id={id} className="input grow mono" value={value} autoComplete="new-password" spellCheck={false} onChange={(e) => onChange(e.target.value)} />
|
||||
<button type="button" className="icon-btn" aria-label={t("Generate a password")} title={t("Generate a password")} onClick={() => onChange(generatePassword())}>
|
||||
<Dices size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
aria-label={t("Copy")}
|
||||
title={t("Copy")}
|
||||
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success(t("Copied")), () => toast.error(t("Could not copy")))}
|
||||
>
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="hint">{t("Pass it on some way other than email to this address.")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordReset({ account, disabled, onDone }: { account: DirectoryAccount; disabled: boolean; onDone: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const first = account.description?.split(" ")[0] || account.name;
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div>
|
||||
{!hasPassword(account) && <p className="hint" style={{ marginTop: 0 }}>{t("This account has no password. It may sign in through a directory or single sign-on.")}</p>}
|
||||
<button className="btn" disabled={disabled} onClick={() => { setValue(generatePassword()); setOpen(true); }}>
|
||||
<KeyRound size={16} /> {t("Set a new password…")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<PasswordField id="admin-reset-password" value={value} onChange={setValue} />
|
||||
<p className="hint">{t("{name} will be signed out of every app and device using the old password.", { name: first })}</p>
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
<div className="row">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy || !value}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateAccount(account.id, passwordPatch(account, value));
|
||||
toast.success(t("New password set for {address}", { address: account.emailAddress ?? account.name }));
|
||||
setOpen(false);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Set password")}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setOpen(false)}>{t("Cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Aliases({ aliases, setAliases, editable, domains, defaultDomain, domainName }: {
|
||||
aliases: EmailAlias[];
|
||||
setAliases: (a: EmailAlias[]) => void;
|
||||
editable: boolean;
|
||||
domains: { id: string; name: string }[];
|
||||
defaultDomain: string;
|
||||
domainName: (id: string) => string;
|
||||
}) {
|
||||
const [local, setLocal] = useState("");
|
||||
const [domain, setDomain] = useState(defaultDomain);
|
||||
const add = () => {
|
||||
const name = local.trim().toLowerCase();
|
||||
if (!name || aliases.some((a) => a.name === name && a.domainId === domain)) return;
|
||||
setAliases([...aliases, { enabled: true, name, domainId: domain }]);
|
||||
setLocal("");
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="row wrap gap-4">
|
||||
{aliases.length ? (
|
||||
aliases.map((a, i) => (
|
||||
<span key={`${a.name}@${a.domainId}`} className="chip notranslate" translate="no">
|
||||
{a.name}@{domainName(a.domainId) || "…"}
|
||||
{editable && (
|
||||
<button className="chip-x" aria-label={t("Remove {address}", { address: `${a.name}@${domainName(a.domainId)}` })} onClick={() => setAliases(aliases.filter((_, j) => j !== i))}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="hint">{t("None")}</span>
|
||||
)}
|
||||
</div>
|
||||
{editable && (
|
||||
<div className="row admin-address mt-8">
|
||||
<input className="input" aria-label={t("New address")} placeholder={t("another name")} value={local} spellCheck={false} onChange={(e) => setLocal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} />
|
||||
<span className="muted">@</span>
|
||||
<select className="input" aria-label={t("Domain")} value={domain} onChange={(e) => setDomain(e.target.value)}>
|
||||
{(domains.some((d) => d.id === defaultDomain) ? domains : [{ id: defaultDomain, name: domainName(defaultDomain) || "…" }, ...domains]).map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-sm" onClick={add} disabled={!local.trim()}>
|
||||
<Plus size={14} /> {t("Add")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editable && <p className="hint">{t("Mail to these addresses is delivered to this account. Changes apply when you save.")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccount({ account, blocked, onDeleted }: { account: DirectoryAccount; blocked: string | null; onDeleted: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const address = account.emailAddress ?? account.name;
|
||||
return (
|
||||
<>
|
||||
<h3>{t("Delete")}</h3>
|
||||
<div className="admin-danger">
|
||||
<p>{blocked ?? t("Deletes the mailbox and everything in it.")}</p>
|
||||
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
|
||||
<Trash2 size={14} /> {t("Delete account…")}
|
||||
</button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={t("Delete {address}?", { address })}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={busy || typed.trim().toLowerCase() !== address.toLowerCase()}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await destroyAccount(account.id);
|
||||
toast.success(t("Deleted {address}", { address }));
|
||||
setOpen(false);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Delete account")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p style={{ marginTop: 0 }}>{t("This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.")}</p>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-delete-confirm">{t("Type {address} to confirm", { address })}</label>
|
||||
<input id="admin-delete-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
|
||||
</div>
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, Search, UserPlus, Users } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { STALWART_CAP } from "@/jmap/client";
|
||||
import { can, type RoleDef } from "@/lib/adminAccess";
|
||||
import {
|
||||
describeDirectoryError,
|
||||
getAccounts,
|
||||
listDomains,
|
||||
listGroups,
|
||||
listRoles,
|
||||
queryAccounts,
|
||||
DISK_QUOTA,
|
||||
type DirectoryAccount,
|
||||
type DirectoryDomain,
|
||||
} from "@/lib/adminDirectory";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { Avatar, Empty, Spinner } from "@/ui/misc";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
|
||||
import { AccountSheet } from "./AccountSheet";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const perms = usePermissions();
|
||||
const session = useSession((s) => s.session);
|
||||
const [text, setText] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [position, setPosition] = useState(0);
|
||||
const [page, setPage] = useState<{ accounts: DirectoryAccount[]; total: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [serverDomains, setServerDomains] = useState<DirectoryDomain[] | null>(null);
|
||||
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
|
||||
const [groups, setGroups] = useState<Map<string, DirectoryAccount>>(new Map());
|
||||
const [loose, setLoose] = useState<DirectoryAccount | null>(null);
|
||||
|
||||
// Typing is not a query per keystroke.
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setQuery(text);
|
||||
setPosition(0);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const q = await queryAccounts({ type: "User", text: query, position, limit: PAGE_SIZE });
|
||||
const accounts = await getAccounts(q.ids);
|
||||
if (!cancelled) setPage({ accounts, total: q.total });
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setPage({ accounts: [], total: 0 });
|
||||
setError(describeDirectoryError(err));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, position, reload]);
|
||||
|
||||
// The lists the account sheet picks from. Each is a nicety: without it the
|
||||
// sheet falls back to what it can see, or offers less.
|
||||
useEffect(() => {
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) void listDomains().then(setServerDomains, () => setServerDomains(null));
|
||||
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null));
|
||||
void listGroups().then((list) => setGroups(new Map(list.map((g) => [g.id, g]))), () => setGroups(new Map()));
|
||||
}, [perms, reload]);
|
||||
|
||||
// An account opened by address that is not on the page being shown.
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedId === "new" || page?.accounts.some((a) => a.id === selectedId)) {
|
||||
setLoose(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getAccounts([selectedId]).then(
|
||||
([a]) => { if (!cancelled) setLoose(a ?? null); },
|
||||
() => { if (!cancelled) setLoose(null); },
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedId, page]);
|
||||
|
||||
const ctx: DirectoryContext = useMemo(() => {
|
||||
const seen = new Map<string, DirectoryDomain>();
|
||||
for (const a of page?.accounts ?? []) {
|
||||
const domain = a.emailAddress?.split("@")[1];
|
||||
if (domain && !seen.has(a.domainId)) seen.set(a.domainId, { id: a.domainId, name: domain });
|
||||
}
|
||||
const ownId = session?.primaryAccounts?.[STALWART_CAP];
|
||||
return {
|
||||
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
|
||||
roles,
|
||||
groups,
|
||||
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
|
||||
};
|
||||
}, [page, serverDomains, roles, groups, session]);
|
||||
|
||||
const selected = selectedId && selectedId !== "new" ? (page?.accounts.find((a) => a.id === selectedId) ?? loose) : null;
|
||||
const close = () => navigate("/admin/accounts");
|
||||
const changed = () => setReload((n) => n + 1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-head">
|
||||
<div className="grow">
|
||||
<h1>{t("Accounts")}</h1>
|
||||
<p className="lead">{t("The people who sign in to mail on the domains you manage.")}</p>
|
||||
</div>
|
||||
{can(perms, "Account", "Create") && (
|
||||
<button className="btn btn-primary" onClick={() => navigate("/admin/accounts/new")}>
|
||||
<UserPlus size={16} /> {t("New account")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<label className="admin-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={t("Search by name or address")}
|
||||
aria-label={t("Search accounts")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{page === null ? (
|
||||
<Spinner />
|
||||
) : page.accounts.length === 0 ? (
|
||||
!error && (
|
||||
<Empty icon={<Users size={32} />} title={query ? t("No accounts match") : t("No accounts yet")}>
|
||||
{query ? t("Nothing on your domains matches “{query}”.", { query }) : undefined}
|
||||
</Empty>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("Account")}</th>
|
||||
<th>{t("Role")}</th>
|
||||
<th>{t("Storage")}</th>
|
||||
<th className="hide-mobile">{t("Groups")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{page.accounts.map((a) => (
|
||||
<tr
|
||||
key={a.id}
|
||||
className={a.id === selectedId ? "selected" : ""}
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/admin/accounts/${a.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(`/admin/accounts/${a.id}`);
|
||||
}
|
||||
}}
|
||||
aria-label={t("Open {address}", { address: a.emailAddress ?? a.name })}
|
||||
>
|
||||
<td>
|
||||
<div className="admin-who">
|
||||
<Avatar who={{ name: a.description || a.name, email: a.emailAddress }} size="sm" />
|
||||
<div className="grow">
|
||||
<div className="admin-who-name truncate">
|
||||
{a.description || a.name}
|
||||
{isSelf(a, ctx) && <span className="badge muted">{t("You")}</span>}
|
||||
</div>
|
||||
<div className="hint truncate notranslate" translate="no">{a.emailAddress}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><RoleLabel account={a} roles={ctx.roles} /></td>
|
||||
<td><StorageMeter account={a} /></td>
|
||||
<td className="hide-mobile muted">
|
||||
<span className="truncate admin-groups">{groupNames(a, ctx.groups) || "—"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pager position={position} shown={page.accounts.length} total={page.total} onMove={setPosition} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedId === "new" || selected) && (
|
||||
<AccountSheet
|
||||
key={selectedId}
|
||||
account={selectedId === "new" ? null : selected}
|
||||
ctx={ctx}
|
||||
onClose={close}
|
||||
onChanged={changed}
|
||||
onCreated={(id) => {
|
||||
changed();
|
||||
navigate(`/admin/accounts/${id}`);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
changed();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupNames(a: DirectoryAccount, groups: Map<string, DirectoryAccount>): string {
|
||||
return Object.keys(a.memberGroupIds ?? {})
|
||||
.map((id) => groups.get(id))
|
||||
.filter(Boolean)
|
||||
.map((g) => g!.description || g!.name)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function RoleLabel({ account, roles }: { account: DirectoryAccount; roles: Map<string, RoleDef> | null }) {
|
||||
const kind = account.roles?.["@type"] ?? "User";
|
||||
return <span className={`admin-role ${kind === "Admin" ? "admin" : kind === "Custom" ? "custom" : ""}`}>{roleName(account, roles)}</span>;
|
||||
}
|
||||
|
||||
function StorageMeter({ account }: { account: DirectoryAccount }) {
|
||||
const used = account.usedDiskQuota ?? 0;
|
||||
const limit = account.quotas?.[DISK_QUOTA] ?? 0;
|
||||
if (!limit) return <span className="muted small">{t("{used} · no limit", { used: formatSize(used) })}</span>;
|
||||
const pct = Math.min(100, Math.round((used / limit) * 100));
|
||||
return (
|
||||
<div className="admin-meter" title={t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) })}>
|
||||
<div className="quota-bar"><span className={pct > 95 ? "danger" : pct > 80 ? "warn" : ""} style={{ width: `${pct}%` }} /></div>
|
||||
<span className="small muted">{t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({ position, shown, total, onMove }: { position: number; shown: number; total: number; onMove: (p: number) => void }) {
|
||||
if (total <= PAGE_SIZE && position === 0) {
|
||||
return <p className="hint admin-count">{plural(total, { one: "{n} account", other: "{n} accounts" })}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="admin-pager">
|
||||
<span className="hint">{t("{from}–{to} of {total}", { from: position + 1, to: position + shown, total })}</span>
|
||||
<button className="icon-btn sm" aria-label={t("Previous page")} disabled={position === 0} onClick={() => onMove(Math.max(0, position - PAGE_SIZE))}>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button className="icon-btn sm" aria-label={t("Next page")} disabled={position + shown >= total} onClick={() => onMove(position + PAGE_SIZE)}>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Globe, User } from "lucide-react";
|
||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode }> = {
|
||||
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
|
||||
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
|
||||
};
|
||||
|
||||
/** The section the address names, or the first the role can open. */
|
||||
export function currentAdminSection(allowed: AdminSection[], requested: string | undefined): AdminSection | undefined {
|
||||
return allowed.find((s) => s === requested) ?? allowed[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Administration's sections, in the folder pane.
|
||||
*
|
||||
* Settings keeps its list inside the page; Administration's pages are tables
|
||||
* that want the width, so the list lives where Mail keeps its folders. On a
|
||||
* phone that puts it in the drawer, which is where every other section's list
|
||||
* already is. Only sections the role can read are listed.
|
||||
*/
|
||||
export function AdminNav() {
|
||||
const [location] = useLocation();
|
||||
const allowed = adminSections(usePermissions());
|
||||
const current = currentAdminSection(allowed, location.split("/")[2]);
|
||||
const groups = [...new Set(allowed.map((s) => ADMIN_SECTIONS[s].group))];
|
||||
return (
|
||||
<nav aria-label={t("Administration")}>
|
||||
{groups.map((group) => (
|
||||
<div key={group}>
|
||||
<div className="nav-section"><span>{t(group)}</span></div>
|
||||
{allowed
|
||||
.filter((s) => ADMIN_SECTIONS[s].group === group)
|
||||
.map((s) => (
|
||||
<Link key={s} href={`/admin/${s}`} className={`nav-item ${current === s ? "active" : ""}`} title={t(ADMIN_SECTIONS[s].label)} aria-current={current === s ? "page" : undefined}>
|
||||
{ADMIN_SECTIONS[s].icon}
|
||||
<span className="nav-label">{t(ADMIN_SECTIONS[s].label)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Redirect } from "wouter";
|
||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { AccountsAdmin } from "./AccountsAdmin";
|
||||
import { DomainsAdmin } from "./DomainsAdmin";
|
||||
import { currentAdminSection } from "./AdminNav";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
|
||||
accounts: (id) => <AccountsAdmin selectedId={id} />,
|
||||
domains: (id) => <DomainsAdmin selectedId={id} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Administration: what the signed-in account's Stalwart role lets it manage.
|
||||
*
|
||||
* The page is only the open section. Its list of sections is in the folder
|
||||
* pane (see AdminNav), so the tables here get the width Settings spends on a
|
||||
* second column. A section the role cannot read -- typed into the address bar,
|
||||
* say -- opens the first one it can.
|
||||
*/
|
||||
export function AdminView({ section, id }: { section?: string; id?: string }) {
|
||||
const allowed = adminSections(usePermissions());
|
||||
// A role taken away since the menu was drawn. Stalwart would refuse every
|
||||
// call anyway; this spares the page of refusals.
|
||||
if (!allowed.length) return <Redirect to="/mail" />;
|
||||
const current = currentAdminSection(allowed, section)!;
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<div className="settings-content admin-content">{RENDER[current](section === current ? id : undefined)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Copy, Globe, Plus, Trash2, X } from "lucide-react";
|
||||
import { can } from "@/lib/adminAccess";
|
||||
import { describeDirectoryError } from "@/lib/adminDirectory";
|
||||
import {
|
||||
createDomain,
|
||||
describeLinked,
|
||||
destroyDomain,
|
||||
dkimAlgorithm,
|
||||
DomainError,
|
||||
getDomains,
|
||||
listDkimKeys,
|
||||
looksLikeDomain,
|
||||
namesOf,
|
||||
normaliseDomain,
|
||||
parseZoneFile,
|
||||
updateDomain,
|
||||
type DirectoryDomainFull,
|
||||
type DkimKey,
|
||||
type Managed,
|
||||
} from "@/lib/adminDomains";
|
||||
import { formatFullDate } from "@/lib/format";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { Spinner, Switch } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
interface Props {
|
||||
/** Null to add one. */
|
||||
id: string | null;
|
||||
/** How many accounts use it, when the list could count them. */
|
||||
accountCount?: number;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
onCreated: (id: string) => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export function ManagedLabel({ value }: { value?: Managed }) {
|
||||
const automatic = value?.["@type"] === "Automatic";
|
||||
return <span className={`admin-role ${automatic ? "admin" : ""}`}>{automatic ? t("Automatic") : t("By hand")}</span>;
|
||||
}
|
||||
|
||||
const STAGE_LABEL: Record<string, string> = { active: "Signing", pending: "Published, not signing yet", retiring: "Retiring", retired: "Retired" };
|
||||
|
||||
const copy = (text: string, done: string) =>
|
||||
void navigator.clipboard?.writeText(text).then(() => toast.success(done), () => toast.error(t("Could not copy")));
|
||||
|
||||
/**
|
||||
* One domain, beside the list: what it is called, where its mail goes, and the
|
||||
* records the world needs to see before any of that works.
|
||||
*
|
||||
* The DNS records are the part people come here for. Stalwart computes them
|
||||
* per domain -- MX, SPF, DKIM, DMARC, the service records, MTA-STS -- so they
|
||||
* are shown one per row, each with its own copy button, because a DNS
|
||||
* provider's form takes one record at a time.
|
||||
*/
|
||||
export function DomainSheet({ id, accountCount, onClose, onChanged, onCreated, onDeleted }: Props) {
|
||||
const perms = usePermissions();
|
||||
const creating = id === null;
|
||||
const editable = creating ? can(perms, "Domain", "Create") : can(perms, "Domain", "Update");
|
||||
const [domain, setDomain] = useState<DirectoryDomainFull | null>(null);
|
||||
const [keys, setKeys] = useState<DkimKey[] | null>(null);
|
||||
const [provider, setProvider] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [aliases, setAliases] = useState<string[]>([]);
|
||||
const [catchAll, setCatchAll] = useState("");
|
||||
const [plus, setPlus] = useState<"Enabled" | "Disabled" | "Custom">("Enabled");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [d] = await getDomains([id], { zoneFile: true });
|
||||
if (cancelled) return;
|
||||
if (!d) {
|
||||
setLoadError(t("This domain no longer exists. Someone may have removed it."));
|
||||
return;
|
||||
}
|
||||
setDomain(d);
|
||||
setDescription(d.description ?? "");
|
||||
setAliases(Object.keys(d.aliases ?? {}));
|
||||
setCatchAll(d.catchAllAddress ?? "");
|
||||
setPlus(d.subAddressing?.["@type"] ?? "Enabled");
|
||||
if (can(perms, "DkimSignature", "Query") && can(perms, "DkimSignature", "Get")) {
|
||||
void listDkimKeys(id).then((k) => { if (!cancelled) setKeys(k); }, () => { if (!cancelled) setKeys(null); });
|
||||
}
|
||||
const serverId = d.dnsManagement?.["@type"] === "Automatic" ? d.dnsManagement.dnsServerId : undefined;
|
||||
if (serverId && can(perms, "DnsServer", "Get")) {
|
||||
void namesOf("DnsServer", [serverId]).then((n) => { if (!cancelled) setProvider(n.get(serverId) ?? null); }, () => {});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setLoadError(describeDirectoryError(err, "domain"));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, perms, revision]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const records = useMemo(() => (domain?.dnsZoneFile ? parseZoneFile(domain.dnsZoneFile) : []), [domain]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (creating) {
|
||||
if (!looksLikeDomain(name)) {
|
||||
setError(t("That doesn't look like a domain name, such as example.com."));
|
||||
return;
|
||||
}
|
||||
const newId = await createDomain({ name, description });
|
||||
toast.success(t("Added {name}. Its DNS records are ready to copy.", { name: normaliseDomain(name) }));
|
||||
onCreated(newId);
|
||||
return;
|
||||
}
|
||||
if (!domain) return;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if ((domain.description ?? "") !== description) patch.description = description.trim() || null;
|
||||
const nextAliases = [...new Set(aliases.map(normaliseDomain).filter(Boolean))];
|
||||
if (JSON.stringify(Object.keys(domain.aliases ?? {}).sort()) !== JSON.stringify([...nextAliases].sort())) {
|
||||
patch.aliases = Object.fromEntries(nextAliases.map((a) => [a, true]));
|
||||
}
|
||||
if ((domain.catchAllAddress ?? "") !== catchAll.trim()) patch.catchAllAddress = catchAll.trim() || null;
|
||||
if ((domain.subAddressing?.["@type"] ?? "Enabled") !== plus && plus !== "Custom") patch.subAddressing = { "@type": plus };
|
||||
if (!Object.keys(patch).length) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await updateDomain(domain.id, patch);
|
||||
toast.success(t("Saved {name}", { name: domain.name }));
|
||||
setRevision((n) => n + 1);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err, "domain"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = creating ? t("Add domain") : (domain?.name ?? "");
|
||||
|
||||
return (
|
||||
<aside className="admin-sheet" aria-label={title}>
|
||||
<div className="admin-sheet-head">
|
||||
<span className="avatar" style={{ background: "var(--accent-soft)", color: "var(--accent-soft-fg)" }} aria-hidden="true"><Globe size={18} /></span>
|
||||
<div className="grow">
|
||||
<h2 className="truncate notranslate" translate="no">{title}</h2>
|
||||
{domain?.createdAt && <div className="hint truncate">{t("Added {date}", { date: formatFullDate(domain.createdAt) })}</div>}
|
||||
</div>
|
||||
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-sheet-body">
|
||||
{loadError ? (
|
||||
<p className="admin-notice error" role="alert">{loadError}</p>
|
||||
) : !creating && !domain ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
{domain?.isEnabled === false && <p className="admin-notice warn"><span>{t("This domain is disabled on the server.")}</span></p>}
|
||||
{!creating && !editable && <p className="admin-notice">{t("Your role lets you view domains but not change them.")}</p>}
|
||||
|
||||
{creating && (
|
||||
<>
|
||||
<h3>{t("Domain")}</h3>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-domain-name">{t("Name")}</label>
|
||||
<input id="admin-domain-name" className="input notranslate" translate="no" placeholder="example.com" value={name} autoComplete="off" spellCheck={false} onChange={(e) => setName(e.target.value)} />
|
||||
<span className="hint">{t("New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.")}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3>{t("Profile")}</h3>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-domain-description">{t("Description")}</label>
|
||||
<input id="admin-domain-description" className="input" value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{!creating && domain && (
|
||||
<>
|
||||
<h3>{t("Other names")}</h3>
|
||||
<AliasList aliases={aliases} setAliases={setAliases} editable={editable} />
|
||||
|
||||
<h3>{t("Delivery")}</h3>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-domain-catchall">{t("Catch-all address")}</label>
|
||||
<input id="admin-domain-catchall" className="input notranslate" translate="no" value={catchAll} disabled={!editable} placeholder={t("None")} spellCheck={false} onChange={(e) => setCatchAll(e.target.value)} />
|
||||
<span className="hint">{t("Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.")}</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={plus !== "Disabled"}
|
||||
disabled={!editable || plus === "Custom"}
|
||||
onChange={(on) => setPlus(on ? "Enabled" : "Disabled")}
|
||||
label={t("Plus addressing")}
|
||||
hint={plus === "Custom" ? t("Set by a custom rule on the server.") : t("Mail to name+anything@ is delivered to name@.")}
|
||||
/>
|
||||
|
||||
<h3>{t("DNS records")}</h3>
|
||||
{domain.dnsManagement?.["@type"] === "Automatic" ? (
|
||||
<p className="hint" style={{ marginTop: 0 }}>
|
||||
{provider ? t("Published automatically through {provider}.", { provider }) : t("Published automatically by the server.")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="hint" style={{ marginTop: 0 }}>{t("Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.")}</p>
|
||||
)}
|
||||
{records.length ? (
|
||||
<>
|
||||
<div className="admin-dns">
|
||||
{records.map((r, i) => (
|
||||
<div className="admin-dns-row" key={i}>
|
||||
<span className="admin-dns-type">{r.type || "?"}</span>
|
||||
<div className="grow">
|
||||
<div className="admin-dns-name notranslate" translate="no">{r.name}</div>
|
||||
<code className="admin-dns-value notranslate" translate="no">{r.value}</code>
|
||||
</div>
|
||||
<button className="icon-btn xs" aria-label={t("Copy {type} record for {name}", { type: r.type, name: r.name })} title={t("Copy value")} onClick={() => copy(r.value, t("Copied"))}>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-sm mt-8" onClick={() => copy(domain.dnsZoneFile ?? "", t("Copied the zone file"))}>
|
||||
<Copy size={14} /> {t("Copy all as a zone file")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="hint">{t("The server returned no records for this domain.")}</p>
|
||||
)}
|
||||
|
||||
{keys && (
|
||||
<>
|
||||
<h3>{t("DKIM keys")}</h3>
|
||||
<p className="hint" style={{ marginTop: 0 }}>
|
||||
{domain.dkimManagement?.["@type"] === "Automatic" ? t("The server creates and rotates these keys itself.") : t("These keys are managed by hand on the server.")}
|
||||
</p>
|
||||
{keys.length ? (
|
||||
<table className="sessions-table">
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.id}>
|
||||
<td><code className="notranslate" translate="no">{k.selector}</code><div className="hint">{dkimAlgorithm(k["@type"])}</div></td>
|
||||
<td><span className={`admin-role ${k.stage === "active" ? "admin" : ""}`}>{t(STAGE_LABEL[k.stage ?? "active"] ?? "Signing")}</span></td>
|
||||
<td className="hint">{k.createdAt ? formatFullDate(k.createdAt) : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="admin-notice warn"><span>{t("No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.")}</span></p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3>{t("Managed by the server")}</h3>
|
||||
<dl className="admin-kv">
|
||||
<dt>{t("DNS records")}</dt><dd><ManagedLabel value={domain.dnsManagement} /></dd>
|
||||
<dt>{t("DKIM keys")}</dt><dd><ManagedLabel value={domain.dkimManagement} /></dd>
|
||||
<dt>{t("Certificate")}</dt><dd><ManagedLabel value={domain.certificateManagement} /></dd>
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{!creating && domain && can(perms, "Domain", "Destroy") && (
|
||||
<RemoveDomain domain={domain} accountCount={accountCount} keys={keys} canRemoveKeys={can(perms, "DkimSignature", "Destroy")} onDeleted={onDeleted} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editable && !loadError && (creating || domain) && (
|
||||
<div className="admin-sheet-foot">
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
|
||||
<button className="btn btn-primary" disabled={busy || (creating && !name.trim())} onClick={() => void save()}>
|
||||
{creating ? t("Add domain") : t("Save changes")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function AliasList({ aliases, setAliases, editable }: { aliases: string[]; setAliases: (a: string[]) => void; editable: boolean }) {
|
||||
const [value, setValue] = useState("");
|
||||
const add = () => {
|
||||
const name = normaliseDomain(value);
|
||||
if (!looksLikeDomain(name) || aliases.includes(name)) return;
|
||||
setAliases([...aliases, name]);
|
||||
setValue("");
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="row wrap gap-4">
|
||||
{aliases.length ? (
|
||||
aliases.map((a) => (
|
||||
<span key={a} className="chip notranslate" translate="no">
|
||||
{a}
|
||||
{editable && (
|
||||
<button className="chip-x" aria-label={t("Remove {address}", { address: a })} onClick={() => setAliases(aliases.filter((x) => x !== a))}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="hint">{t("None")}</span>
|
||||
)}
|
||||
</div>
|
||||
{editable && (
|
||||
<div className="row mt-8">
|
||||
<input className="input grow notranslate" translate="no" aria-label={t("Another name for this domain")} placeholder="example.net" value={value} spellCheck={false} onChange={(e) => setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} />
|
||||
<button className="btn btn-sm" onClick={add} disabled={!looksLikeDomain(value)}>
|
||||
<Plus size={14} /> {t("Add")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editable && <p className="hint">{t("Mail to the same address at any of these names reaches the same account. Changes apply when you save.")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveDomain({ domain, accountCount, keys, canRemoveKeys, onDeleted }: {
|
||||
domain: DirectoryDomainFull;
|
||||
accountCount?: number;
|
||||
keys: DkimKey[] | null;
|
||||
canRemoveKeys: boolean;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const keyCount = keys?.length ?? 0;
|
||||
const blocked = accountCount
|
||||
? plural(accountCount, { one: "{n} account uses this domain. Move or delete it first.", other: "{n} accounts use this domain. Move or delete them first." })
|
||||
: keyCount && !canRemoveKeys
|
||||
? t("Its DKIM keys have to be removed first, and your role can't remove them.")
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<h3>{t("Remove")}</h3>
|
||||
<div className="admin-danger">
|
||||
<p>{blocked ?? t("The server stops accepting mail for this domain.")}</p>
|
||||
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
|
||||
<Trash2 size={14} /> {t("Remove domain…")}
|
||||
</button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={t("Remove {name}?", { name: domain.name })}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={busy || normaliseDomain(typed) !== domain.name}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await destroyDomain(domain.id, canRemoveKeys ? (keys ?? []).map((k) => k.id) : []);
|
||||
toast.success(t("Removed {name}", { name: domain.name }));
|
||||
setOpen(false);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof DomainError && err.type === "objectIsLinked" && err.linked.length
|
||||
? t("The server kept the domain: it is still used by {things}.", { things: describeLinked(err.linked) })
|
||||
: describeDirectoryError(err, "domain"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Remove domain")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
{keyCount
|
||||
? plural(keyCount, { one: "The server stops accepting mail for this domain, and its {n} DKIM key is deleted. This can't be undone.", other: "The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone." })
|
||||
: t("The server stops accepting mail for this domain. This can't be undone.")}
|
||||
</p>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-domain-confirm">{t("Type {address} to confirm", { address: domain.name })}</label>
|
||||
<input id="admin-domain-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
|
||||
</div>
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, Globe, Plus, Search } from "lucide-react";
|
||||
import { can } from "@/lib/adminAccess";
|
||||
import { describeDirectoryError } from "@/lib/adminDirectory";
|
||||
import { countAccounts, getDomains, namesOf, queryDomains, type DirectoryDomainFull } from "@/lib/adminDomains";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
import { DomainSheet, ManagedLabel } from "./DomainSheet";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function DomainsAdmin({ selectedId }: { selectedId?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const perms = usePermissions();
|
||||
const [text, setText] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [position, setPosition] = useState(0);
|
||||
const [page, setPage] = useState<{ domains: DirectoryDomainFull[]; total: number } | null>(null);
|
||||
const [counts, setCounts] = useState<Map<string, number>>(new Map());
|
||||
const [tenants, setTenants] = useState<Map<string, string>>(new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setQuery(text);
|
||||
setPosition(0);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const q = await queryDomains({ text: query, position, limit: PAGE_SIZE });
|
||||
const domains = await getDomains(q.ids);
|
||||
if (cancelled) return;
|
||||
setPage({ domains, total: q.total });
|
||||
// Both are extras on top of the list, and each needs a permission of its own.
|
||||
if (can(perms, "Account", "Query")) void countAccounts(domains.map((d) => d.id)).then((c) => { if (!cancelled) setCounts(c); });
|
||||
const tenantIds = [...new Set(domains.map((d) => d.memberTenantId).filter((x): x is string => Boolean(x)))];
|
||||
if (tenantIds.length && can(perms, "Tenant", "Get")) void namesOf("Tenant", tenantIds).then((n) => { if (!cancelled) setTenants(n); }, () => {});
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setPage({ domains: [], total: 0 });
|
||||
setError(describeDirectoryError(err, "domain"));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, position, reload, perms]);
|
||||
|
||||
const close = () => navigate("/admin/domains");
|
||||
const showTenants = tenants.size > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-head">
|
||||
<div className="grow">
|
||||
<h1>{t("Domains")}</h1>
|
||||
<p className="lead">{t("Where your addresses live, and the DNS records that let mail arrive and be trusted.")}</p>
|
||||
</div>
|
||||
{can(perms, "Domain", "Create") && (
|
||||
<button className="btn btn-primary" onClick={() => navigate("/admin/domains/new")}>
|
||||
<Plus size={16} /> {t("Add domain")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<label className="admin-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input className="input" type="search" value={text} onChange={(e) => setText(e.target.value)} placeholder={t("Search domains")} aria-label={t("Search domains")} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{page === null ? (
|
||||
<Spinner />
|
||||
) : page.domains.length === 0 ? (
|
||||
!error && <Empty icon={<Globe size={32} />} title={query ? t("No domains match") : t("No domains yet")} />
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("Domain")}</th>
|
||||
<th>{t("Accounts")}</th>
|
||||
<th>{t("DNS records")}</th>
|
||||
<th className="hide-mobile">{t("DKIM")}</th>
|
||||
<th className="hide-mobile">{t("Certificate")}</th>
|
||||
{showTenants && <th className="hide-mobile">{t("Tenant")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{page.domains.map((d) => (
|
||||
<tr
|
||||
key={d.id}
|
||||
className={d.id === selectedId ? "selected" : ""}
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/admin/domains/${d.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(`/admin/domains/${d.id}`);
|
||||
}
|
||||
}}
|
||||
aria-label={t("Open {address}", { address: d.name })}
|
||||
>
|
||||
<td>
|
||||
<div className="admin-who-name notranslate" translate="no">
|
||||
{d.name}
|
||||
{d.isEnabled === false && <span className="badge muted">{t("Disabled")}</span>}
|
||||
</div>
|
||||
{Object.keys(d.aliases ?? {}).length > 0 && (
|
||||
<div className="hint truncate notranslate" translate="no">{t("also {names}", { names: Object.keys(d.aliases ?? {}).join(", ") })}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted" style={{ fontVariantNumeric: "tabular-nums" }}>{counts.has(d.id) ? counts.get(d.id) : "—"}</td>
|
||||
<td><ManagedLabel value={d.dnsManagement} /></td>
|
||||
<td className="hide-mobile"><ManagedLabel value={d.dkimManagement} /></td>
|
||||
<td className="hide-mobile"><ManagedLabel value={d.certificateManagement} /></td>
|
||||
{showTenants && <td className="hide-mobile muted">{d.memberTenantId ? (tenants.get(d.memberTenantId) ?? "—") : "—"}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{page.total <= PAGE_SIZE && position === 0 ? (
|
||||
<p className="hint admin-count">{plural(page.total, { one: "{n} domain", other: "{n} domains" })}</p>
|
||||
) : (
|
||||
<div className="admin-pager">
|
||||
<span className="hint">{t("{from}–{to} of {total}", { from: position + 1, to: position + page.domains.length, total: page.total })}</span>
|
||||
<button className="icon-btn sm" aria-label={t("Previous page")} disabled={position === 0} onClick={() => setPosition(Math.max(0, position - PAGE_SIZE))}><ChevronLeft size={18} /></button>
|
||||
<button className="icon-btn sm" aria-label={t("Next page")} disabled={position + page.domains.length >= page.total} onClick={() => setPosition(position + PAGE_SIZE)}><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedId && (
|
||||
<DomainSheet
|
||||
key={selectedId}
|
||||
id={selectedId === "new" ? null : selectedId}
|
||||
accountCount={selectedId === "new" ? undefined : counts.get(selectedId)}
|
||||
onClose={close}
|
||||
onChanged={() => setReload((n) => n + 1)}
|
||||
onCreated={(id) => {
|
||||
setReload((n) => n + 1);
|
||||
navigate(`/admin/domains/${id}`);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
setReload((n) => n + 1);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Router } from "wouter";
|
||||
import { memoryLocation } from "wouter/memory-location";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import type { DirectoryAccount } from "@/lib/adminDirectory";
|
||||
import { AccountSheet } from "../AccountSheet";
|
||||
import type { DirectoryContext } from "../directoryContext";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
|
||||
|
||||
function signIn(permissions: string[], username = "[email protected]") {
|
||||
useSession.setState({
|
||||
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:stalwart:jmap": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
|
||||
});
|
||||
}
|
||||
|
||||
const account = (over: Partial<DirectoryAccount>): DirectoryAccount => ({
|
||||
id: "u1",
|
||||
"@type": "User",
|
||||
name: "ada",
|
||||
domainId: "d1",
|
||||
emailAddress: "[email protected]",
|
||||
description: "Ada Lovelace",
|
||||
roles: { "@type": "User" },
|
||||
credentials: { "0": { "@type": "Password", secret: "[********]" } },
|
||||
...over,
|
||||
});
|
||||
|
||||
const ctx: DirectoryContext = { domains: [{ id: "d1", name: "example.com" }], roles: null, groups: new Map(), self: { ids: new Set(["self"]), address: "[email protected]" } };
|
||||
|
||||
const button = (host: HTMLElement, text: string) => [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
|
||||
|
||||
/**
|
||||
* The guards that stand in for checks Stalwart does not make. A store test
|
||||
* cannot see these: they are what the sheet renders, and what it leaves out.
|
||||
*/
|
||||
describe("the account sheet", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const render = async (a: DirectoryAccount) => {
|
||||
const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Router hook={hook}>
|
||||
<AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />
|
||||
</Router>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("shows an account that outranks the viewer read-only, password included", async () => {
|
||||
signIn(HELPDESK);
|
||||
await render(account({ roles: { "@type": "Admin" } }));
|
||||
expect(host.textContent).toContain("permissions yours doesn't");
|
||||
expect(button(host, "Set a new password")?.disabled).toBe(true);
|
||||
expect((host.querySelector("#admin-description") as HTMLInputElement).disabled).toBe(true);
|
||||
expect(host.textContent).not.toContain("Save changes");
|
||||
});
|
||||
|
||||
it("lets the same viewer edit an ordinary account, but not delete it", async () => {
|
||||
signIn(HELPDESK);
|
||||
await render(account({}));
|
||||
expect(button(host, "Set a new password")?.disabled).toBe(false);
|
||||
expect(host.textContent).toContain("Save changes");
|
||||
expect(host.textContent).not.toContain("Delete account");
|
||||
});
|
||||
|
||||
it("sends your own password to Settings, and keeps your role and account out of reach", async () => {
|
||||
signIn([...HELPDESK, "sysAccountDestroy"]);
|
||||
await render(account({ id: "self", emailAddress: "[email protected]" }));
|
||||
expect(host.textContent).toContain("Change your own password in");
|
||||
expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull();
|
||||
expect(button(host, "Set a new password")).toBeUndefined();
|
||||
expect((host.querySelector('select[aria-label="Role"]') as HTMLSelectElement).disabled).toBe(true);
|
||||
expect(button(host, "Delete account")?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Router } from "wouter";
|
||||
import { memoryLocation } from "wouter/memory-location";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import { AdminNav } from "../AdminNav";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const signIn = (permissions: string[]) =>
|
||||
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
|
||||
|
||||
/** The folder pane's list of Administration sections: only what the role can read. */
|
||||
describe("the Administration list in the folder pane", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const render = async (path: string) => {
|
||||
const { hook } = memoryLocation({ path });
|
||||
await act(async () => {
|
||||
root.render(<Router hook={hook}><AdminNav /></Router>);
|
||||
});
|
||||
};
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("lists each readable section under its group and marks the open one", async () => {
|
||||
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
|
||||
await render("/admin/domains/d1");
|
||||
expect([...host.querySelectorAll(".nav-section")].map((e) => e.textContent)).toEqual(["Directory", "Mail"]);
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
|
||||
});
|
||||
|
||||
it("treats a bare /admin as the first section, which is what the page opens", async () => {
|
||||
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
|
||||
await render("/admin");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Accounts");
|
||||
});
|
||||
|
||||
it("leaves out what the role cannot read", async () => {
|
||||
signIn(["sysDomainQuery", "sysDomainGet"]);
|
||||
await render("/admin");
|
||||
expect(host.textContent).not.toContain("Accounts");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const domain = {
|
||||
id: "d1",
|
||||
name: "example.com",
|
||||
aliases: {},
|
||||
subAddressing: { "@type": "Custom" },
|
||||
dnsManagement: { "@type": "Manual" },
|
||||
dkimManagement: { "@type": "Automatic" },
|
||||
certificateManagement: { "@type": "Manual" },
|
||||
dnsZoneFile: 'example.com. IN MX 10 mail.example.com.\nexample.com. IN TXT "v=spf1 mx -all"\n',
|
||||
};
|
||||
|
||||
vi.mock("@/lib/adminDomains", async (original) => ({
|
||||
...(await original<typeof import("@/lib/adminDomains")>()),
|
||||
getDomains: vi.fn(async () => [domain]),
|
||||
listDkimKeys: vi.fn(async () => [{ id: "k1", "@type": "Dkim1Ed25519Sha256", selector: "v1-ed25519", stage: "active" }]),
|
||||
}));
|
||||
|
||||
const { DomainSheet } = await import("../DomainSheet");
|
||||
|
||||
const signIn = (permissions: string[]) =>
|
||||
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
|
||||
|
||||
const button = (host: HTMLElement, text: string) => [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
|
||||
|
||||
/**
|
||||
* What decides whether a domain can be removed is not the button but what
|
||||
* still uses it, and some of that is the domain's own keys.
|
||||
*/
|
||||
describe("the domain sheet", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const render = async (accountCount: number | undefined) => {
|
||||
await act(async () => {
|
||||
root.render(<DomainSheet id="d1" accountCount={accountCount} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
|
||||
});
|
||||
await act(async () => {});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("lists the records one per row, unquoted", async () => {
|
||||
signIn(["sysDomainGet", "sysDomainQuery"]);
|
||||
await render(0);
|
||||
expect(host.querySelectorAll(".admin-dns-row").length).toBe(2);
|
||||
expect(host.textContent).toContain("v=spf1 mx -all");
|
||||
expect(host.textContent).not.toContain('"v=spf1');
|
||||
});
|
||||
|
||||
it("will not offer removal while accounts use the domain", async () => {
|
||||
signIn(["sysDomainGet", "sysDomainQuery", "sysDomainDestroy", "sysDkimSignatureQuery", "sysDkimSignatureGet", "sysDkimSignatureDestroy"]);
|
||||
await render(3);
|
||||
expect(host.textContent).toContain("3 accounts use this domain");
|
||||
expect(button(host, "Remove domain")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("will not offer removal when the keys that must go first cannot be removed", async () => {
|
||||
signIn(["sysDomainGet", "sysDomainQuery", "sysDomainDestroy", "sysDkimSignatureQuery", "sysDkimSignatureGet"]);
|
||||
await render(0);
|
||||
expect(host.textContent).toContain("your role can't remove them");
|
||||
expect(button(host, "Remove domain")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a plus-addressing rule set on the server alone", async () => {
|
||||
signIn(["sysDomainGet", "sysDomainQuery", "sysDomainUpdate"]);
|
||||
await render(0);
|
||||
expect(host.textContent).toContain("Set by a custom rule on the server.");
|
||||
expect((host.querySelector('button[role="switch"]') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RoleDef } from "@/lib/adminAccess";
|
||||
import type { DirectoryAccount, DirectoryDomain } from "@/lib/adminDirectory";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export interface DirectoryContext {
|
||||
/** Domains to offer. Read from the server when allowed, else seen on accounts. */
|
||||
domains: DirectoryDomain[];
|
||||
/** Null when the viewer cannot read roles, which `outranks` treats as unknown. */
|
||||
roles: Map<string, RoleDef> | null;
|
||||
groups: Map<string, DirectoryAccount>;
|
||||
/** Registry ids and addresses that are the signed-in account itself. */
|
||||
self: { ids: Set<string>; address: string };
|
||||
}
|
||||
|
||||
export function isSelf(a: Pick<DirectoryAccount, "id" | "emailAddress">, ctx: DirectoryContext): boolean {
|
||||
return ctx.self.ids.has(a.id) || (!!a.emailAddress && a.emailAddress.toLowerCase() === ctx.self.address);
|
||||
}
|
||||
|
||||
export function roleName(a: Pick<DirectoryAccount, "roles">, roles: Map<string, RoleDef> | null): string {
|
||||
const r = a.roles;
|
||||
if (!r || r["@type"] === "User") return t("User");
|
||||
if (r["@type"] === "Admin") return t("Administrator");
|
||||
const names = Object.keys(r.roleIds ?? {}).map((id) => roles?.get(id)?.description).filter(Boolean);
|
||||
return names.length ? names.join(", ") : t("Custom role");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { permissionSet, type Permissions } from "@/lib/adminAccess";
|
||||
|
||||
/**
|
||||
* The signed-in account's permissions, as a set, stable between renders.
|
||||
*
|
||||
* Keyed on the contents, not the array. The session is fetched again whenever
|
||||
* a response carries a different session state, and each fetch brings a new
|
||||
* array with the same names in it; a set rebuilt from identity would re-run
|
||||
* everything that depends on it, whose requests could bring another refresh.
|
||||
*/
|
||||
export function usePermissions(): Permissions {
|
||||
// An installation with administration off sends none; this is belt and braces.
|
||||
const key = useSession((s) => (s.session?.ihasmail?.administration === false ? "" : (s.session?.ihasmail?.permissions ?? []).join(",")));
|
||||
return useMemo(() => permissionSet(key ? key.split(",") : []), [key]);
|
||||
}
|
||||
@@ -9,8 +9,10 @@ export function ComposerDock() {
|
||||
if (!drafts.length) return null;
|
||||
// On mobile only the active composer is shown (full screen); others are minimized bars.
|
||||
const visible = isMobile ? drafts.filter((d) => d.key === activeKey || d.minimized) : drafts;
|
||||
// On desktop a full-screen composer stands alone: the rest are hidden until it is restored.
|
||||
const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized);
|
||||
return (
|
||||
<div className="composer-dock">
|
||||
<div className={`composer-dock${hasMaximized ? " has-maximized" : ""}`}>
|
||||
{visible.map((d) => (
|
||||
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCompose, type Draft } from "@/store/compose";
|
||||
import { ComposerDock } from "../ComposerDock";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// The question is which composers the dock puts on screen, not what is inside
|
||||
// them, so each composer is a bare marker carrying the state it was given.
|
||||
vi.mock("../Composer", () => ({
|
||||
Composer: ({ draft }: { draft: Draft }) => (
|
||||
<div className={`composer ${draft.maximized ? "maximized" : ""} ${draft.minimized ? "minimized" : ""}`} data-key={draft.key} />
|
||||
),
|
||||
}));
|
||||
|
||||
/* jsdom has no matchMedia; each test says which side of the 768px breakpoint it stands at. */
|
||||
function setWidth(px: number) {
|
||||
window.matchMedia = ((q: string) => ({
|
||||
matches: /max-width:\s*(\d+)px/.test(q) ? px <= Number(RegExp.$1) : false,
|
||||
media: q,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
const draft = (key: string, init: Partial<Draft> = {}) => ({ key, minimized: false, maximized: false, ...init }) as Draft;
|
||||
|
||||
describe("ComposerDock with a full-screen composer", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
useCompose.setState({ drafts: [], activeKey: null });
|
||||
});
|
||||
|
||||
const render = (drafts: Draft[], activeKey: string) => {
|
||||
useCompose.setState({ drafts, activeKey });
|
||||
act(() => root.render(<ComposerDock />));
|
||||
};
|
||||
const dock = () => host.querySelector(".composer-dock")!;
|
||||
|
||||
it("marks the dock so the other composers are hidden behind it", () => {
|
||||
setWidth(1300);
|
||||
render([draft("a"), draft("b", { maximized: true }), draft("c")], "b");
|
||||
expect(dock().classList.contains("has-maximized")).toBe(true);
|
||||
// Every composer stays mounted: the hiding is the stylesheet's, so nothing being typed elsewhere is lost.
|
||||
expect(host.querySelectorAll(".composer").length).toBe(3);
|
||||
});
|
||||
|
||||
it("leaves the dock alone while nobody is full screen", () => {
|
||||
setWidth(1300);
|
||||
render([draft("a"), draft("b")], "b");
|
||||
expect(dock().classList.contains("has-maximized")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not count a full-screen composer that has since been minimised", () => {
|
||||
setWidth(1300);
|
||||
render([draft("a"), draft("b", { maximized: true, minimized: true })], "a");
|
||||
expect(dock().classList.contains("has-maximized")).toBe(false);
|
||||
});
|
||||
|
||||
it("is not a phone concern: there the active composer is already the only one open", () => {
|
||||
setWidth(400);
|
||||
render([draft("a"), draft("b", { maximized: true })], "b");
|
||||
expect(dock().classList.contains("has-maximized")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user