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:
2026-09-13 15:39:16 -07:00
parent d279fe8f90
commit 1dafb4bc79
23 changed files with 1783 additions and 30 deletions
@@ -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");
});
});