Add Domains to Administration
A role that can read domains now finds a Domains section beside Accounts: list and search with each domain's account count and whether its DNS, DKIM and certificate are managed automatically; add a domain; edit its description, other names, catch-all address and plus addressing; copy its DNS records one at a time or as a zone file; see its DKIM keys and their stage; and remove it once no accounts use it. The records come from the zone file Stalwart computes per domain. A long DKIM record, which the BIND serialiser splits into quoted chunks, is joined back into the single value a DNS provider's form wants. Removing a domain takes its DKIM keys first, in the same request, because the server will not remove a domain its keys still name. Removal is not offered while accounts use the domain, or when the role cannot remove the keys. The Administration nav is now built from the sections the role can read, and the menu appears when there is at least one. The mock gains domains, DKIM keys and zone files. 61 new strings, translated in all nine catalogues; strings falling back to English stay at 16.
This commit is contained in:
+34
-8
@@ -1104,8 +1104,8 @@ redirects them to their mail if they type its address in.
|
|||||||
At sign-in the server already asks Stalwart's `GET /api/account` for the
|
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
|
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
|
hands them to the browser with the session. The menu appears for an account
|
||||||
that can both query and read accounts (`sysAccountQuery`, `sysAccountGet`),
|
that can query and read accounts (`sysAccountQuery`, `sysAccountGet`) or
|
||||||
and each control inside is there only when the matching permission is:
|
domains (`sysDomainQuery`, `sysDomainGet`), and each control inside is there only when the matching permission is:
|
||||||
**New account** with `sysAccountCreate`, editing with `sysAccountUpdate`,
|
**New account** with `sysAccountCreate`, editing with `sysAccountUpdate`,
|
||||||
**Delete** with `sysAccountDestroy`. A system administrator, a tenant
|
**Delete** with `sysAccountDestroy`. A system administrator, a tenant
|
||||||
administrator and a custom helpdesk role each see the same screen shaped to
|
administrator and a custom helpdesk role each see the same screen shaped to
|
||||||
@@ -1140,6 +1140,32 @@ 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
|
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.
|
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.
|
||||||
|
|
||||||
## An operator can turn it off
|
## An operator can turn it off
|
||||||
|
|
||||||
`ADMINISTRATION=0` at launch removes it for everyone, and not only from the
|
`ADMINISTRATION=0` at launch removes it for everyone, and not only from the
|
||||||
@@ -1158,10 +1184,9 @@ 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
|
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.
|
the meantime Stalwart refuses what is no longer allowed.
|
||||||
|
|
||||||
Accounts is the first section. Groups, mailing lists, roles, domains (with
|
Accounts and domains are the first two sections. Groups, mailing lists, roles
|
||||||
their DNS records and DKIM keys) and tenants are Stalwart capabilities the same
|
and tenants are Stalwart capabilities the same screen is laid out to take;
|
||||||
screen is laid out to take; reporting, queues, logs and server settings are
|
reporting, queues, logs and server settings are deliberately out of scope.
|
||||||
deliberately out of scope.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1617,8 +1642,9 @@ 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
|
`MOCK_NO_REGISTRY=1` omits the Stalwart capability so the sign-in refusal can be
|
||||||
tested.
|
tested.
|
||||||
|
|
||||||
Administration works against it too, with a directory of about thirty accounts
|
Administration works against it too, with a directory of about thirty accounts,
|
||||||
behind the same permission names Stalwart uses. `MOCK_ROLE` decides who the
|
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
|
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
|
that may view and edit accounts but not create or delete them — or `user`, who
|
||||||
is not offered the menu at all.
|
is not offered the menu at all.
|
||||||
|
|||||||
+3
-1
@@ -32,7 +32,9 @@ 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
|
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).
|
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||||
|
|
||||||
- **Administration has not yet been exercised against a live Stalwart.** It was built on 2026-09-13 against the 0.16.22 source and the mock, which reproduces the shapes read there — lists as index-keyed objects, sets as `{"id": true}`, masked secrets, AND-only filters — and it has not touched a real server. Four things are read from source rather than proved: that `/api/account` lists permissions in camelCase (`sysAccountGet`) as the enum serialises them, where the documentation shows kebab-case — both are accepted, so the menu works either way; that a new password written to `credentials/<index>/secret` is hashed and keeps the credential's id; that the Basic credential ihasmail proxies with reaches the admin `x:` methods as it already reaches the self-service ones; and that Stalwart skips its grant check when only a password changes, which is the reason the outranking guard exists at all. The last is worth reproducing rather than trusting in either direction. Query and get are sent as two requests rather than one with a back-reference, because whether the registry methods resolve references was not checked.
|
- **Administration has not yet been exercised against a live Stalwart.** Accounts were built on 2026-09-13 against the 0.16.22 source and the mock, which reproduces the shapes read there — lists as index-keyed objects, sets as `{"id": true}`, masked secrets, AND-only filters — and it has not touched a real server. Four things are read from source rather than proved: that `/api/account` lists permissions in camelCase (`sysAccountGet`) as the enum serialises them, where the documentation shows kebab-case — both are accepted, so the menu works either way; that a new password written to `credentials/<index>/secret` is hashed and keeps the credential's id; that the Basic credential ihasmail proxies with reaches the admin `x:` methods as it already reaches the self-service ones; and that Stalwart skips its grant check when only a password changes, which is the reason the outranking guard exists at all. The last is worth reproducing rather than trusting in either direction. Query and get are sent as two requests rather than one with a back-reference, because whether the registry methods resolve references was not checked.
|
||||||
|
|
||||||
|
- **The Domains section is read from source as well, on the same terms.** Five things are worth confirming on a live server before trusting them: that `dnsZoneFile` is written as `name IN TYPE value` with long TXT records split into a parenthesised run, which is what `dns-update`'s BIND serialiser does on its main branch while Stalwart pins 0.5; that `catchAllAddress` takes a whole address rather than a local part; that `x:DkimSignature/query` accepts a `domainId` filter, as the server's own record builder queries by it; that a refused delete's `linkedObjects` name each object by type as `{object, id}`; and that removing a domain's DKIM keys and then the domain in one request succeeds, since automatic DKIM gives every new domain keys and a domain its keys name cannot be removed. The zone-file reader keeps any line it cannot parse as a row of its own, so a format that differs shows up as an odd row rather than a missing record.
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +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.
|
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||||
|
|
||||||
- **Administration beyond accounts.** The Administration menu manages accounts today — see [FEATURES.md](FEATURES.md#administration). Groups, mailing lists, roles, domains with their DNS records and DKIM keys, DNS providers and tenants are all Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent. 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -65,3 +65,31 @@ test("an administrator can delete an account, and a group with members is kept",
|
|||||||
assert.deepEqual((set({ destroy: ["u101"] }) as { destroyed: string[] }).destroyed, ["u101"]);
|
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");
|
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" } } }) 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}"/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,10 +68,55 @@ export function createDirectory(opts: Options) {
|
|||||||
const [userLocal, userDomain] = splitAddress(opts.user);
|
const [userLocal, userDomain] = splitAddress(opts.user);
|
||||||
let counter = 100;
|
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[] = [
|
const domains: Obj[] = [
|
||||||
{ id: "d1", name: userDomain, aliases: {}, description: null },
|
domain("d1", userDomain, { ...managed(true, true, true), aliases: { [`mail.${userDomain}`]: true }, description: "Main domain" }),
|
||||||
{ id: "d2", name: userDomain === "example.org" ? "example.net" : "example.org", aliases: {}, description: null },
|
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[] = [
|
const roles: Obj[] = [
|
||||||
{ id: "r1", description: "User", enabledPermissions: flags(USER_PERMISSIONS), disabledPermissions: {}, roleIds: {} },
|
{ id: "r1", description: "User", enabledPermissions: flags(USER_PERMISSIONS), disabledPermissions: {}, roleIds: {} },
|
||||||
@@ -133,7 +178,9 @@ export function createDirectory(opts: Options) {
|
|||||||
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)));
|
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 view = (o: Obj, properties: unknown): Obj => {
|
||||||
const full: Obj = { ...o, emailAddress: addressOf(o) };
|
const full: Obj = { ...o };
|
||||||
|
if (accounts.includes(o)) full.emailAddress = addressOf(o);
|
||||||
|
if (domains.includes(o)) full.dnsZoneFile = zoneFile(o);
|
||||||
if (full.credentials) {
|
if (full.credentials) {
|
||||||
full.credentials = Object.fromEntries(Object.entries(full.credentials as Obj).map(([k, c]) => [k, { ...(c as Obj), secret: MASKED }]));
|
full.credentials = Object.fromEntries(Object.entries(full.credentials as Obj).map(([k, c]) => [k, { ...(c as Obj), secret: MASKED }]));
|
||||||
}
|
}
|
||||||
@@ -273,6 +320,67 @@ export function createDirectory(opts: Options) {
|
|||||||
},
|
},
|
||||||
"x:Domain/get": get(domains, "sysDomainGet"),
|
"x:Domain/get": get(domains, "sysDomainGet"),
|
||||||
"x:Domain/query": query(() => domains, "sysDomainQuery", (o, f) => matchText(o, f.text) && matchText(o, f.name)),
|
"x:Domain/query": query(() => domains, "sysDomainQuery", (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 ?? "");
|
||||||
|
if (!/^([a-z0-9-]+\.)+[a-z0-9-]{2,}$/.test(name)) { notCreated[cid] = setError("invalidProperties", "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);
|
||||||
|
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", (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/get": get(roles, "sysRoleGet"),
|
||||||
"x:Role/query": query(() => roles, "sysRoleQuery", (o, f) => matchText(o, f.description)),
|
"x:Role/query": query(() => roles, "sysRoleQuery", (o, f) => matchText(o, f.description)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ADMIN_BASELINE, can, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
import { ADMIN_BASELINE, adminSections, can, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||||
|
|
||||||
const set = (...p: string[]) => permissionSet(p);
|
const set = (...p: string[]) => permissionSet(p);
|
||||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||||
@@ -19,6 +19,12 @@ describe("who is offered administration", () => {
|
|||||||
expect(hasAdministration(permissionSet(undefined))).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", () => {
|
it("reads one permission per object and operation", () => {
|
||||||
expect(can(helpdesk, "Account", "Update")).toBe(true);
|
expect(can(helpdesk, "Account", "Update")).toBe(true);
|
||||||
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
|
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,14 +27,24 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole
|
|||||||
return perms.has(`sys${object}${op}`);
|
return perms.has(`sys${object}${op}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminSection = "accounts" | "domains";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to offer Administration at all.
|
* The sections an account may open, in the order they are listed.
|
||||||
*
|
*
|
||||||
* Accounts are the only section so far, and a list that cannot be opened is
|
* A list that cannot be read is not worth an entry, so each takes both halves
|
||||||
* not worth a menu entry, so it takes both halves of reading one.
|
* 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 {
|
export function hasAdministration(perms: Permissions): boolean {
|
||||||
return can(perms, "Account", "Query") && can(perms, "Account", "Get");
|
return adminSections(perms).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(", ");
|
||||||
|
}
|
||||||
@@ -55,6 +55,63 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
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: accounts ───────────────────────────────────
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
"Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.",
|
"Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.",
|
||||||
"Administration": "Verwaltung",
|
"Administration": "Verwaltung",
|
||||||
@@ -1444,6 +1501,13 @@ export const catalog: Catalog = {
|
|||||||
"no address": "keine Adresse",
|
"no address": "keine Adresse",
|
||||||
},
|
},
|
||||||
plurals: {
|
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 ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
"{n} accounts": { one: "{n} Konto", other: "{n} Konten" },
|
"{n} accounts": { one: "{n} Konto", other: "{n} Konten" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -47,6 +47,63 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
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: accounts ───────────────────────────────────
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
"Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.",
|
"Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.",
|
||||||
"Administration": "Administración",
|
"Administration": "Administración",
|
||||||
@@ -1417,6 +1474,13 @@ export const catalog: Catalog = {
|
|||||||
"no address": "ninguna dirección",
|
"no address": "ninguna dirección",
|
||||||
},
|
},
|
||||||
plurals: {
|
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 ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
"{n} accounts": { one: "{n} cuenta", other: "{n} cuentas" },
|
"{n} accounts": { one: "{n} cuenta", other: "{n} cuentas" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -52,6 +52,63 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
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: accounts ───────────────────────────────────
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
"Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.",
|
"Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.",
|
||||||
"Administration": "Administration",
|
"Administration": "Administration",
|
||||||
@@ -1422,6 +1479,13 @@ export const catalog: Catalog = {
|
|||||||
"no address": "aucune adresse",
|
"no address": "aucune adresse",
|
||||||
},
|
},
|
||||||
plurals: {
|
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 ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
"{n} accounts": { one: "{n} compte", other: "{n} comptes" },
|
"{n} accounts": { one: "{n} compte", other: "{n} comptes" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -46,6 +46,63 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
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: accounts ───────────────────────────────────
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
"Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。",
|
"Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。",
|
||||||
"Administration": "管理",
|
"Administration": "管理",
|
||||||
@@ -1425,6 +1482,13 @@ export const catalog: Catalog = {
|
|||||||
"no address": "アドレスなし",
|
"no address": "アドレスなし",
|
||||||
},
|
},
|
||||||
plurals: {
|
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 ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
"{n} accounts": { other: "{n} 件のアカウント" },
|
"{n} accounts": { other: "{n} 件のアカウント" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -43,6 +43,63 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
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: accounts ───────────────────────────────────
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
"Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.",
|
"Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.",
|
||||||
"Administration": "Beheer",
|
"Administration": "Beheer",
|
||||||
@@ -1413,6 +1470,13 @@ export const catalog: Catalog = {
|
|||||||
"no address": "geen adres",
|
"no address": "geen adres",
|
||||||
},
|
},
|
||||||
plurals: {
|
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 ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
"{n} accounts": { one: "{n} account", other: "{n} accounts" },
|
"{n} accounts": { one: "{n} account", other: "{n} accounts" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -50,6 +50,63 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
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: accounts ───────────────────────────────────
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
"Change your own password in {settings}.": "Altere sua própria senha em {settings}.",
|
"Change your own password in {settings}.": "Altere sua própria senha em {settings}.",
|
||||||
"Administration": "Administração",
|
"Administration": "Administração",
|
||||||
@@ -1420,6 +1477,13 @@ export const catalog: Catalog = {
|
|||||||
"no address": "nenhum endereço",
|
"no address": "nenhum endereço",
|
||||||
},
|
},
|
||||||
plurals: {
|
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 ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
"{n} accounts": { one: "{n} conta", other: "{n} contas" },
|
"{n} accounts": { one: "{n} conta", other: "{n} contas" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -49,6 +49,63 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
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: accounts ───────────────────────────────────
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
"Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.",
|
"Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.",
|
||||||
"Administration": "Администрирование",
|
"Administration": "Администрирование",
|
||||||
@@ -1419,6 +1476,13 @@ export const catalog: Catalog = {
|
|||||||
"no address": "нет адреса",
|
"no address": "нет адреса",
|
||||||
},
|
},
|
||||||
plurals: {
|
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 ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
"{n} accounts": { one: "{n} учётная запись", few: "{n} учётные записи", many: "{n} учётных записей", other: "{n} учётной записи" },
|
"{n} accounts": { one: "{n} учётная запись", few: "{n} учётные записи", many: "{n} учётных записей", other: "{n} учётной записи" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -43,6 +43,63 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
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: accounts ───────────────────────────────────
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
"Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.",
|
"Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.",
|
||||||
"Administration": "Адміністрування",
|
"Administration": "Адміністрування",
|
||||||
@@ -1413,6 +1470,13 @@ export const catalog: Catalog = {
|
|||||||
"no address": "немає адреси",
|
"no address": "немає адреси",
|
||||||
},
|
},
|
||||||
plurals: {
|
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 ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
"{n} accounts": { one: "{n} обліковий запис", few: "{n} облікові записи", many: "{n} облікових записів", other: "{n} облікового запису" },
|
"{n} accounts": { one: "{n} обліковий запис", few: "{n} облікові записи", many: "{n} облікових записів", other: "{n} облікового запису" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -45,6 +45,63 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
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: accounts ───────────────────────────────────
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
"Change your own password in {settings}.": "请在{settings}中更改您自己的密码。",
|
"Change your own password in {settings}.": "请在{settings}中更改您自己的密码。",
|
||||||
"Administration": "管理",
|
"Administration": "管理",
|
||||||
@@ -1424,6 +1481,13 @@ export const catalog: Catalog = {
|
|||||||
"no address": "无地址",
|
"no address": "无地址",
|
||||||
},
|
},
|
||||||
plurals: {
|
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 ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
"{n} accounts": { other: "{n} 个账户" },
|
"{n} accounts": { other: "{n} 个账户" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1727,6 +1727,15 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
|||||||
.admin-danger p { margin: 0 0 10px; color: var(--fg-muted); font-size: .92em; }
|
.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 { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
|
||||||
.admin-danger-btn:hover:not(:disabled) { background: var(--danger-soft); }
|
.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; } }
|
@media (prefers-reduced-motion: reduce) { .admin-sheet { animation: none; } }
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
|
|||||||
@@ -1,32 +1,48 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { Link, Redirect, useLocation } from "wouter";
|
import { Link, Redirect, useLocation } from "wouter";
|
||||||
import { ArrowLeft, User } from "lucide-react";
|
import { ArrowLeft, Globe, User } from "lucide-react";
|
||||||
import { hasAdministration } from "@/lib/adminAccess";
|
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { AccountsAdmin } from "./AccountsAdmin";
|
import { AccountsAdmin } from "./AccountsAdmin";
|
||||||
|
import { DomainsAdmin } from "./DomainsAdmin";
|
||||||
import { usePermissions } from "./usePermissions";
|
import { usePermissions } from "./usePermissions";
|
||||||
|
|
||||||
|
const SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode; render: (id?: string) => ReactNode }> = {
|
||||||
|
accounts: { group: "Directory", label: "Accounts", icon: <User size={18} />, render: (id) => <AccountsAdmin selectedId={id} /> },
|
||||||
|
domains: { group: "Mail", label: "Domains", icon: <Globe size={18} />, render: (id) => <DomainsAdmin selectedId={id} /> },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Administration: what the signed-in account's Stalwart role lets it manage.
|
* Administration: what the signed-in account's Stalwart role lets it manage.
|
||||||
*
|
*
|
||||||
* Laid out like Settings, because it is the same kind of place -- a list of
|
* Laid out like Settings, because it is the same kind of place -- a list of
|
||||||
* sections and the one that is open -- and on a phone it behaves the same way,
|
* sections and the one that is open -- and on a phone it behaves the same way,
|
||||||
* the list first and a section on its own. Accounts is the only section so
|
* the list first and a section on its own. Only the sections the role can read
|
||||||
* far; the nav is written as a list so the next one is an entry, not a rework.
|
* are listed; a section typed into the address bar that it cannot read opens
|
||||||
|
* the first one it can.
|
||||||
*/
|
*/
|
||||||
export function AdminView({ section, id }: { section?: string; id?: string }) {
|
export function AdminView({ section, id }: { section?: string; id?: string }) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const perms = usePermissions();
|
const allowed = adminSections(usePermissions());
|
||||||
// Typed in by hand, or a role taken away since the menu was drawn. Stalwart
|
// Typed in by hand, or a role taken away since the menu was drawn. Stalwart
|
||||||
// would refuse every call anyway; this spares the page of refusals.
|
// would refuse every call anyway; this spares the page of refusals.
|
||||||
if (!hasAdministration(perms)) return <Redirect to="/mail" />;
|
if (!allowed.length) return <Redirect to="/mail" />;
|
||||||
|
const current = allowed.find((s) => s === section) ?? allowed[0]!;
|
||||||
|
const groups = [...new Set(allowed.map((s) => SECTIONS[s].group))];
|
||||||
return (
|
return (
|
||||||
<div className={`settings-layout admin-layout ${section ? "section" : "root"}`}>
|
<div className={`settings-layout admin-layout ${section ? "section" : "root"}`}>
|
||||||
<nav className="settings-nav" aria-label={t("Administration")}>
|
<nav className="settings-nav" aria-label={t("Administration")}>
|
||||||
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Directory")}</span></div>
|
{groups.map((group) => (
|
||||||
<Link href="/admin/accounts" className={`nav-item ${!section || section === "accounts" ? "active" : ""}`}>
|
<div key={group}>
|
||||||
<User size={18} />
|
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t(group)}</span></div>
|
||||||
<span className="nav-label">{t("Accounts")}</span>
|
{allowed.filter((s) => SECTIONS[s].group === group).map((s) => (
|
||||||
</Link>
|
<Link key={s} href={`/admin/${s}`} className={`nav-item ${current === s ? "active" : ""}`}>
|
||||||
|
{SECTIONS[s].icon}
|
||||||
|
<span className="nav-label">{t(SECTIONS[s].label)}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="settings-content admin-content">
|
<div className="settings-content admin-content">
|
||||||
{section && (
|
{section && (
|
||||||
@@ -34,7 +50,7 @@ export function AdminView({ section, id }: { section?: string; id?: string }) {
|
|||||||
<ArrowLeft size={16} /> {t("Administration")}
|
<ArrowLeft size={16} /> {t("Administration")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<AccountsAdmin selectedId={id} />
|
{SECTIONS[current].render(section === current ? id : undefined)}
|
||||||
</div>
|
</div>
|
||||||
</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));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
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));
|
||||||
|
} 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),
|
||||||
|
);
|
||||||
|
} 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user