Group the admin and calendar modules, and stop calling screenshots docs

web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.

  lib/admin/     adminAccess, adminDashboard, adminDirectory, adminDomains,
                 adminGroups, adminLists, adminRoles, adminTenants
  lib/calendar/  appointment, availabilityWindow, eventDrag, ics, recurrence

Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.

recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.

docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.

No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
This commit is contained in:
2026-09-15 22:44:53 -07:00
parent 0bde2df69d
commit f7712b1c1e
72 changed files with 108 additions and 108 deletions
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/admin/adminAccess";
const set = (...p: string[]) => permissionSet(p);
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
const helpdesk = set("sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "jmapEmailGet");
const roles = new Map<string, RoleDef>([
["user", { id: "user", enabledPermissions: { jmapEmailGet: true } }],
["helpdesk", { id: "helpdesk", enabledPermissions: { sysAccountGet: true, sysAccountQuery: true, sysAccountUpdate: true }, roleIds: { user: true } }],
["dns", { id: "dns", enabledPermissions: { sysDnsServerUpdate: true }, roleIds: { user: true } }],
["loop", { id: "loop", enabledPermissions: {}, roleIds: { loop: true } }],
]);
describe("who is offered administration", () => {
it("needs both halves of reading the account list to list accounts", () => {
// Groups are accounts to the server, so they come with the same two permissions.
expect(adminSections(set("sysAccountQuery", "sysAccountGet"))).toEqual(["dashboard", "accounts", "groups"]);
// A query alone is a count on the dashboard, not a list.
expect(adminSections(set("sysAccountQuery"))).toEqual(["dashboard"]);
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
expect(hasAdministration(permissionSet(undefined))).toBe(false);
});
it("offers each section only with both halves of reading it", () => {
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["dashboard", "domains"]);
expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true);
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["dashboard", "accounts", "groups"]);
});
it("gives the dashboard a card for each number the role can read", () => {
expect(dashboardCards(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"))).toEqual(["users", "domains"]);
expect(dashboardCards(set("sysQueuedMessageQuery"))).toEqual(["pending"]);
// The history takes its get as well: the query only finds the records.
expect(dashboardCards(set("sysMetricQuery"))).toEqual([]);
expect(dashboardCards(set("sysMetricQuery", "sysMetricGet"))).toEqual(["memory", "received", "sent"]);
expect(adminSections(set("jmapEmailGet"))).toEqual([]);
});
it("reads one permission per object and operation", () => {
expect(can(helpdesk, "Account", "Update")).toBe(true);
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
expect(can(helpdesk, "Domain", "Get")).toBe(false);
});
});
/**
* Stalwart checks a grant, but not a password change or a delete. Without this,
* anyone allowed to edit accounts could take over one that can do more.
*/
describe("an account that outranks the viewer", () => {
it("an ordinary user never does", () => {
expect(outranks(helpdesk, { roles: { "@type": "User" } }, null)).toBe(false);
expect(outranks(helpdesk, {}, null)).toBe(false);
});
it("an administrator does, unless the viewer is one too", () => {
expect(outranks(helpdesk, { roles: { "@type": "Admin" } }, roles)).toBe(true);
expect(outranks(everything, { roles: { "@type": "Admin" } }, roles)).toBe(false);
});
it("a custom role does when it carries something the viewer lacks", () => {
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, roles)).toBe(false);
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } } }, roles)).toBe(true);
});
it("a role that cannot be read counts against the target, not for it", () => {
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, null)).toBe(true);
expect(outranks(everything, { roles: { "@type": "Custom", roleIds: { gone: true } } }, roles)).toBe(true);
});
it("extra permissions on the account itself are counted", () => {
expect(outranks(helpdesk, { roles: { "@type": "User" }, permissions: { "@type": "Merge", enabledPermissions: { sysDomainDestroy: true } } }, roles)).toBe(true);
// Replace ignores the roles entirely, so only what it lists matters.
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } }, permissions: { "@type": "Replace", enabledPermissions: { jmapEmailGet: true } } }, roles)).toBe(false);
});
it("survives a role that names itself", () => {
expect(resolveRoles(["loop"], roles)).toEqual(new Set());
});
});
describe("granting a role", () => {
it("is offered only for roles whose every permission the viewer holds", () => {
expect(canGrantRole(helpdesk, "helpdesk", roles)).toBe(true);
expect(canGrantRole(helpdesk, "dns", roles)).toBe(false);
expect(canGrantRole(everything, "missing", roles)).toBe(false);
});
});
describe("generated passwords", () => {
it("are four groups of five unambiguous characters", () => {
const p = generatePassword();
expect(p).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
expect(p).not.toMatch(/[01lIO]/);
});
it("skip bytes that would favor the start of the alphabet", () => {
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
// the first 36 characters twice as often. Bytes of 220 and up are dropped
// and more are drawn, so a batch of nothing but those costs a draw.
let call = 0;
const source = (n: number) => (call++ === 0 ? new Uint8Array(n).fill(250) : Uint8Array.from({ length: n }, (_, i) => i));
expect(generatePassword(source)).toBe("abcde-fghjk-mnpqr-stuvw");
expect(call).toBe(2);
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from "vitest";
import { client, JmapMethodError } from "@/jmap/client";
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/admin/adminDashboard";
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
describe("the dashboard's message numbers", () => {
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
const stats = summarizeMetrics([
counter("queue.message-queued", 6),
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
counter("queue.authenticated-message-queued", 2),
counter("queue.dsn-queued", 1),
counter("queue.report-queued", 3),
// Recorded, but not either number.
counter("message-ingest.ham", 50),
]);
expect(stats.received).toBe(10);
expect(stats.sent).toBe(6);
});
it("reads memory from the newest gauge, not the first one listed", () => {
const stats = summarizeMetrics([
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
]);
expect(stats.memory).toEqual({ bytes: 300, at: "2026-09-15T14:00:00Z" });
});
it("tells a history that records nothing from a quiet day", () => {
expect(summarizeMetrics([]).recorded).toBe(false);
const quiet = summarizeMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
});
});
describe("the dashboard's queries", () => {
it("counts users rather than accounts, and asks for no ids", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 5 });
expect(await countObjects("Account")).toBe(5);
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User" }, limit: 0, calculateTotal: true });
await countObjects("QueuedMessage");
expect(call).toHaveBeenLastCalledWith("x:QueuedMessage/query", { limit: 0, calculateTotal: true });
call.mockRestore();
});
it("filters the history with Stalwart's comparison names, and pages the gets", async () => {
// A bare `timestamp` or `after` is unsupportedFilter on a live server.
vi.spyOn(client, "maxObjectsInGet", "get").mockReturnValue(2);
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
if (method === "x:Metric/query") return (args as { position: number }).position === 0 ? { ids: ["a", "b"] } : { ids: ["c"] };
return { list: ((args as { ids: string[] }).ids).map((id) => counter("queue.message-queued", 1, id)) };
});
const records = await loadMetrics(new Date("2026-09-14T15:30:00.123Z"));
expect(records).toHaveLength(3);
expect(call.mock.calls[0]).toEqual([
"x:Metric/query",
{
filter: { timestampIsGreaterThanOrEqual: "2026-09-14T15:30:00Z", metric: ["queue.message-queued", "queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued", "server.memory"] },
sort: [{ property: "timestamp", isAscending: false }],
position: 0,
limit: 2,
},
]);
vi.restoreAllMocks();
});
it("treats only a forbidden answer as the server refusing", () => {
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "forbidden" }))).toBe(true);
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "serverFail" }))).toBe(false);
expect(isRefused(new Error("offline"))).toBe(false);
});
});
describe("the card grid", () => {
it("never leaves a row short when the cards can be divided evenly", () => {
for (const n of [1, 2, 3, 4, 6]) {
const { wide, mid } = balancedColumns(n);
expect(n % wide, `${n} cards across ${wide}`).toBe(0);
expect(n % mid, `${n} cards across ${mid}`).toBe(0);
expect(wide).toBeLessThanOrEqual(4);
}
expect(balancedColumns(6)).toEqual({ wide: 3, mid: 2 });
expect(balancedColumns(3)).toEqual({ wide: 3, mid: 1 });
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/admin/adminDirectory";
describe("setting a password", () => {
it("writes into the existing password credential, keeping its place", () => {
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "2": { "@type": "Password" as const, secret: "[********]" } } };
expect(passwordPatch(account, "new secret")).toEqual({ "credentials/2/secret": "new secret" });
});
it("adds one after the last index when the account has none", () => {
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "3": { "@type": "ApiKey" as const } } };
expect(passwordPatch(account, "s")).toEqual({ "credentials/4": { "@type": "Password", secret: "s" } });
expect(passwordPatch({}, "s")).toEqual({ "credentials/0": { "@type": "Password", secret: "s" } });
expect(hasPassword(account)).toBe(false);
});
});
describe("lists written back", () => {
it("re-index aliases the way the server stores a list", () => {
expect(aliasList([{ name: "b", domainId: "d1" }, { name: "c", domainId: "d2", enabled: false }])).toEqual({
"0": { enabled: true, name: "b", domainId: "d1", description: null },
"1": { enabled: false, name: "c", domainId: "d2", description: null },
});
});
it("change the disk limit without touching the other quotas", () => {
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, 7)).toEqual({ maxEmails: 10, maxDiskQuota: 7 });
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, null)).toEqual({ maxEmails: 10 });
expect(quotasWithDisk(undefined, 0)).toEqual({});
});
});
describe("explaining a refusal", () => {
it("says what a taken address means", () => {
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", "exists"))).toMatch(/already in use/);
});
it("keeps the server's own words for a password policy", () => {
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Password must be at least 8 characters long.", ["secret"]))).toContain("at least 8 characters");
});
it("handles a method-level refusal as well as a set error", () => {
expect(describeDirectoryError({ type: "forbidden", message: "x:Account/set: forbidden" })).toMatch(/refused/);
});
});
describe("the account query", () => {
it("filters on @type, the property's name on the object", async () => {
// A live 0.16 server answers a plain `type` with "unsupportedFilter - type"
// and fails the whole list, which is how this was found.
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
await queryAccounts({ type: "User", text: " ada ", position: 50, limit: 50 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", text: "ada" }, position: 50, limit: 50, calculateTotal: true });
call.mockRestore();
});
});
/**
* Stalwart explains a refusal in English, and none of it should reach an
* interface in another language as it is. Each case below is a refusal a
* live server gave, or one its source says it gives.
*/
describe("refusals in the reader's language", () => {
it("recognizes the registry's validators and says it again, without the server's words", () => {
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
expect(domain).toMatch(/isn't a valid domain name/);
expect(domain).not.toContain("Invalid domain name");
expect(describeDirectoryError(new DirectoryError("invalidPatch", "Invalid email address", ["catchAllAddress"]), "domain")).toMatch(/full address/);
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Invalid email local part", ["name"]))).toMatch(/before the @/);
});
it("never echoes a description it does not know", () => {
const text = describeDirectoryError(new DirectoryError("invalidPatch", "Something only the server would say", ["whatever"]));
expect(text).not.toContain("Something only the server would say");
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not allowed to do that thing"))).not.toContain("not allowed to do that thing");
expect(describeDirectoryError(new DirectoryError("someNewType", "Brand new English"))).not.toContain("Brand new English");
});
it("tells a grant refusal and a directory-backed account apart from a plain no", () => {
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not authorized to grant permissions: sysDomainDestroy."))).toMatch(/permissions your own role/);
expect(describeDirectoryError(new DirectoryError("forbidden", "Cannot set credentials for accounts in an external directory."))).toMatch(/external directory/);
});
it("words a clash and a missing object for what it was about", () => {
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined, ["name"]), "domain")).toMatch(/domain name is already in use/);
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined))).toMatch(/address is already in use/);
expect(describeDirectoryError(new DirectoryError("notFound", undefined), "domain")).toMatch(/domain no longer exists/);
});
it("explains ihasmail's own refusals by their code, not their English message", () => {
const own = { status: 403, code: "administration_needs_own_device", message: "Administration is only available when signed in on a device marked as your own (x:Account/query)." };
expect(describeDirectoryError(own)).toMatch(/marked as your own/);
expect(describeDirectoryError(own)).not.toContain("x:Account/query");
expect(describeDirectoryError({ status: 403, code: "administration_disabled", message: "…" })).toMatch(/turned off/);
expect(describeDirectoryError({ method: "x:Account/query", type: "unsupportedFilter", message: "x:Account/query: unsupportedFilter - type" })).toBe("The mail server could not carry out the request (unsupportedFilter).");
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/admin/adminDomains";
/**
* Written the way Stalwart's BIND serializer writes it (dns-update's
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
* parenthesized 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(normalizeDomain(" Example.COM. ")).toBe("example.com");
});
it("are checked loosely before the server decides", () => {
expect(looksLikeDomain("mail.example.co.uk")).toBe(true);
expect(looksLikeDomain("example")).toBe(false);
expect(looksLikeDomain("exa mple.com")).toBe(false);
expect(looksLikeDomain("-bad.example.com")).toBe(false);
});
});
describe("explaining what still uses a domain", () => {
it("counts by kind", () => {
expect(describeLinked(["Account", "Account", "DkimSignature", "MailingList", "Whatever"])).toBe("2 accounts, 1 DKIM key, 1 mailing list, 1 other item");
});
it("names a key's algorithm from its type", () => {
expect(dkimAlgorithm("Dkim1Ed25519Sha256")).toBe("Ed25519 · DKIM1");
expect(dkimAlgorithm("Dkim2RsaSha256")).toBe("RSA · DKIM2");
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/admin/adminGroups";
describe("group membership", () => {
it("is a patch to each member, one pointer each, so no other membership moves", () => {
// Stalwart's set patch adds a key on `true` and removes it on `null`, and
// leaves every other key in the set as it was.
expect(membershipPatch(["u1", "u2"], "g1", true)).toEqual({ u1: { "memberGroupIds/g1": true }, u2: { "memberGroupIds/g1": true } });
expect(membershipPatch(["u1"], "g1", false)).toEqual({ u1: { "memberGroupIds/g1": null } });
});
it("counts members as users whose memberships name the group, asking for no ids", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 4 });
expect(await countMembers(["g1"])).toEqual(new Map([["g1", 4]]));
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberGroupIds: "g1" }, limit: 0, calculateTotal: true });
call.mockRestore();
});
it("leaves a count out rather than showing a failed one as none", async () => {
const call = vi.spyOn(client, "call").mockRejectedValue(new Error("offline"));
expect(await countMembers(["g1"])).toEqual(new Map());
call.mockRestore();
});
});
describe("creating and deleting a group", () => {
it("creates an account of type Group, with nothing a person needs to sign in", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "g9" } } });
expect(await createGroup({ name: " sales ", domainId: "d1", description: "", roles: { "@type": "Default" }, diskQuotaBytes: null })).toBe("g9");
const create = (call.mock.calls[0]![1] as { create: { n: Record<string, unknown> } }).create.n;
expect(create).toMatchObject({ "@type": "Group", name: "sales", domainId: "d1", description: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {} });
expect(create).not.toHaveProperty("credentials");
expect(create).not.toHaveProperty("encryptionAtRest");
expect(create).not.toHaveProperty("memberGroupIds");
call.mockRestore();
});
it("takes the members out before deleting, and deletes nothing if that fails", async () => {
const call = vi.spyOn(client, "call").mockResolvedValueOnce({ updated: { u1: null } }).mockResolvedValueOnce({ destroyed: ["g1"] });
await destroyGroup("g1", ["u1"]);
expect(call.mock.calls.map((c) => [c[0], Object.keys(c[1] as object)])).toEqual([
["x:Account/set", ["update"]],
["x:Account/set", ["destroy"]],
]);
call.mockReset();
call.mockResolvedValueOnce({ notUpdated: { u1: { type: "forbidden" } } });
await expect(destroyGroup("g1", ["u1"])).rejects.toMatchObject({ type: "forbidden" });
expect(call).toHaveBeenCalledTimes(1);
call.mockRestore();
});
it("goes straight to the delete for a group with no members", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ destroyed: ["g1"] });
await destroyGroup("g1", []);
expect(call).toHaveBeenCalledTimes(1);
expect(call).toHaveBeenCalledWith("x:Account/set", { destroy: ["g1"] });
call.mockRestore();
});
it("round-trips a group's roles, which are Default or Custom", () => {
for (const roles of [{ "@type": "Default" } as const, { "@type": "Custom", roleIds: { r1: true, r2: true } } as const]) {
expect(groupRolesFromKey(groupRoleKey(roles))).toEqual(roles);
}
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/admin/adminLists";
describe("a mailing list's recipients", () => {
it("are saved as what was added and removed, one pointer each", () => {
// The live server adds a set key on `true`, removes it on `null`, and
// leaves the rest -- so a recipient added elsewhere meanwhile survives.
expect(recipientsPatch(["[email protected]", "[email protected]"], ["[email protected]", "[email protected]"])).toEqual({
"recipients/[email protected]": null,
"recipients/[email protected]": true,
});
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
});
it("compare without regard to case, and escape what a pointer cannot hold", () => {
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
expect(recipientsPatch([], ["odd/[email protected]"])).toEqual({ "recipients/[email protected]": true });
});
it("come out of a paste of names, commas and angle brackets, and keep what isn't an address", () => {
expect(parseAddresses('Ada Lovelace <[email protected]>, [email protected]; "Alan" [email protected]\[email protected] mailto:[email protected]')).toEqual({
addresses: ["[email protected]", "[email protected]", "[email protected]", "[email protected]"],
rejected: [],
});
expect(parseAddresses("ada@, @example.org, someone@nowhere")).toEqual({ addresses: [], rejected: ["ada@", "@example.org", "someone@nowhere"] });
});
});
describe("the list calls", () => {
it("search on text, and leave the filter out when there is none", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
await queryLists({ text: " board ", position: 50, limit: 50 });
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { filter: { text: "board" }, position: 50, limit: 50, calculateTotal: true });
await queryLists({});
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { position: 0, calculateTotal: true });
call.mockRestore();
});
it("create one with its recipients as a set", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "l9" } } });
expect(await createList({ name: "team", domainId: "d1", description: " ", recipients: ["[email protected]", "[email protected]"] })).toBe("l9");
expect(call).toHaveBeenCalledWith("x:MailingList/set", {
create: { n: { name: "team", domainId: "d1", description: null, recipients: { "[email protected]": true, "[email protected]": true }, aliases: {} } },
});
call.mockRestore();
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { permissionSet } from "@/lib/admin/adminAccess";
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/admin/adminRoles";
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
const roles = new Map<string, DirectoryRole>([
["user", { id: "user", description: "User", enabledPermissions: flags("jmapEmailGet", "jmapEmailUpdate") }],
["help", { id: "help", description: "Helpdesk", enabledPermissions: flags("sysAccountGet"), disabledPermissions: flags("jmapEmailUpdate"), roleIds: flags("user") }],
["lead", { id: "lead", description: "Lead", enabledPermissions: flags("sysAccountUpdate"), roleIds: flags("help") }],
]);
describe("what a role holds", () => {
it("follows every base, and a denial anywhere in the tree wins", () => {
// Stalwart unions enabled with enabled and disabled with disabled across
// the tree, then takes the disabled away (permissions.rs).
expect([...effectivePermissions(roles.get("lead")!, roles, "lead")].sort()).toEqual(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
const { granted, denied } = inherited(["help"], roles, "lead");
expect(granted.get("jmapEmailGet")).toBe("help");
expect(denied.get("jmapEmailUpdate")).toBe("help");
});
it("changes a set one pointer at a time", () => {
expect(setPatch("enabledPermissions", ["a", "b"], new Set(["b", "c"]))).toEqual({ "enabledPermissions/a": null, "enabledPermissions/c": true });
expect(setPatch("roleIds", [], [])).toEqual({});
});
it("will not build on itself, or on a role already built on it", () => {
expect(canBuildOn("help", "help", roles)).toBe(false);
expect(canBuildOn("help", "lead", roles)).toBe(false);
expect(canBuildOn("lead", "user", roles)).toBe(true);
expect(canBuildOn(null, "lead", roles)).toBe(true);
});
it("is read-only to a viewer missing anything enabled in its tree, denied or not", () => {
const viewer = permissionSet(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
// jmapEmailUpdate is denied on Helpdesk but enabled on User beneath it: a
// grant Stalwart would check, and a delete it would not.
expect(roleOutranks(viewer, roles.get("lead")!, roles)).toBe(true);
expect(roleOutranks(permissionSet([...viewer, "jmapEmailUpdate"]), roles.get("lead")!, roles)).toBe(false);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/admin/adminTenants";
describe("a tenant's limits", () => {
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
const before = { maxAccounts: 25, maxDomains: 2, maxOauthClients: 7 };
expect(quotasPatch(before, { maxAccounts: 30, maxDomains: null, maxGroups: 5, maxRoles: null })).toEqual({
"quotas/maxAccounts": 30,
"quotas/maxDomains": null,
"quotas/maxGroups": 5,
});
expect(quotasPatch(before, { maxAccounts: 25 })).toEqual({});
});
});
describe("what a tenant holds", () => {
it("is counted with a memberTenantId filter per kind, users and groups apart", async () => {
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
const f = (args as { filter: Record<string, unknown> }).filter;
if (method === "x:Role/query") throw new Error("forbidden");
return { total: method === "x:Account/query" && f["@type"] === "Group" ? 2 : 1 };
});
expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1, dkimKeys: 1 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberTenantId: "t1" }, limit: 0, calculateTotal: true });
expect(call).toHaveBeenCalledWith("x:Domain/query", { filter: { memberTenantId: "t1" }, limit: 0, calculateTotal: true });
call.mockRestore();
});
it("moves a domain in and out by its memberTenantId", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ updated: { d4: null } });
await setDomainTenant("d4", "t1");
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: "t1" } } });
await setDomainTenant("d4", null);
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: null } } });
call.mockRestore();
});
});
describe("a tenant's logo", () => {
it("is drawn only from https or an image data URL", () => {
expect(drawableLogo("https://example.com/logo.png")).toBe("https://example.com/logo.png");
expect(drawableLogo("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
expect(drawableLogo("http://example.com/logo.png")).toBeNull();
expect(drawableLogo("javascript:alert(1)")).toBeNull();
expect(drawableLogo("data:text/html;base64,AAAA")).toBeNull();
expect(drawableLogo(null)).toBeNull();
});
});
+183
View File
@@ -0,0 +1,183 @@
/**
* What the signed-in account may administer, read from the permissions Stalwart
* reported for it at sign-in.
*
* None of this is a security boundary, and nothing here should read as one.
* Every administrative call is a JMAP `x:` method sent through the ordinary
* proxy, and Stalwart checks each of them against the credential making it --
* scoping a tenant administrator's queries to their own tenant, and refusing a
* write the account may not make. What this decides is only what the client
* *offers*: a menu that appears for the people it can do something for, and
* buttons that are there when pressing them would work.
*
* The one place it is more than presentation is `outranks`, which stands in
* for a check Stalwart does not make. See there.
*/
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant" | "QueuedMessage" | "Metric";
export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy";
export type Permissions = ReadonlySet<string>;
export function permissionSet(list: readonly string[] | null | undefined): Permissions {
return new Set(list ?? []);
}
export function can(perms: Permissions, object: AdminObject, op: AdminOp): boolean {
return perms.has(`sys${object}${op}`);
}
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "tenants" | "roles" | "domains";
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
/**
* The dashboard's cards an account may see.
*
* A count is a query with `calculateTotal`, so a query alone earns one. The
* three read from the metric history need the get as well, since the query
* only finds the records. Stalwart scopes the first three to a tenant
* administrator's own tenancy; the metric history has no tenant in it at all,
* and the Tenant Administrator role Stalwart creates does not hold it -- which
* is how a tenant's dashboard comes to show only what is theirs.
*/
export function dashboardCards(perms: Permissions): DashboardCard[] {
const out: DashboardCard[] = [];
if (can(perms, "Account", "Query")) out.push("users");
if (can(perms, "Domain", "Query")) out.push("domains");
if (can(perms, "QueuedMessage", "Query")) out.push("pending");
if (can(perms, "Metric", "Query") && can(perms, "Metric", "Get")) out.push("memory", "received", "sent");
return out;
}
/**
* The sections an account may open, in the order they are listed.
*
* A list that cannot be read is not worth an entry, so each takes both halves
* of reading one: the query that finds the objects and the get that shows them.
* The dashboard comes first, and is there whenever it has a card to show.
*/
export function adminSections(perms: Permissions): AdminSection[] {
const out: AdminSection[] = [];
if (dashboardCards(perms).length) out.push("dashboard");
// Groups are accounts to the server, behind the same two permissions.
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts", "groups");
if (can(perms, "MailingList", "Query") && can(perms, "MailingList", "Get")) out.push("lists");
if (can(perms, "Tenant", "Query") && can(perms, "Tenant", "Get")) out.push("tenants");
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) out.push("roles");
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
return out;
}
/** Whether to offer Administration at all: when there is a section to open. */
export function hasAdministration(perms: Permissions): boolean {
return adminSections(perms).length > 0;
}
/**
* What an administrator holds, at the least: Stalwart's built-in Tenant
* Administrator role, for the parts of it that manage people and domains.
* Anyone who has all of this can already do anything to the accounts an
* "Administrator" account could.
*/
export const ADMIN_BASELINE: readonly string[] = (["Account", "Domain", "Role", "MailingList"] as const).flatMap((o) =>
(["Get", "Query", "Create", "Update", "Destroy"] as const).map((op) => `sys${o}${op}`),
);
export type UserRoles = { "@type": "User" } | { "@type": "Admin" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
export type PermissionsMode =
| { "@type": "Inherit" }
| { "@type": "Merge" | "Replace"; enabledPermissions?: Record<string, boolean>; disabledPermissions?: Record<string, boolean> };
export interface RoleDef {
id: string;
description?: string | null;
enabledPermissions?: Record<string, boolean>;
roleIds?: Record<string, boolean>;
}
/**
* Whether an account can do something the viewer cannot.
*
* Stalwart checks that a caller holds every permission they grant -- when
* roles or permissions change, and when an account is created. It does not
* check when only a password changes, and it does not check a delete. So an
* account allowed to edit accounts could reset the password of one with far
* more rights than its own and sign in as it. ihasmail refuses to offer that,
* and treats such an account as read-only.
*
* It errs toward refusing. A role that cannot be read -- the viewer lacks
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
* an unknown grant is not a grant the viewer can be shown to hold. What it
* cannot see is tenancy: an "Administrator" account is a tenant administrator
* inside a tenant and a server administrator outside one, and a tenant-scoped
* viewer is not told which it is looking at. It never sees the second kind,
* which is why comparing against the administrator baseline is enough there.
*/
export function outranks(
viewer: Permissions,
target: { roles?: UserRoles | null; permissions?: PermissionsMode | null },
roles: ReadonlyMap<string, RoleDef> | null,
): boolean {
let granted = new Set<string>();
const kind = target.roles?.["@type"] ?? "User";
if (kind === "Admin") {
if (!ADMIN_BASELINE.every((p) => viewer.has(p))) return true;
} else if (kind === "Custom") {
const ids = Object.keys((target.roles as { roleIds?: Record<string, boolean> }).roleIds ?? {});
const resolved = resolveRoles(ids, roles);
if (!resolved) return true;
granted = resolved;
}
const mode = target.permissions;
if (mode && mode["@type"] !== "Inherit") {
const enabled = Object.keys(mode.enabledPermissions ?? {});
granted = mode["@type"] === "Replace" ? new Set(enabled) : new Set([...granted, ...enabled]);
}
for (const p of granted) if (!viewer.has(p)) return true;
return false;
}
/** Every permission a set of roles grants, nested roles included; null if any cannot be read. */
export function resolveRoles(ids: readonly string[], roles: ReadonlyMap<string, RoleDef> | null): Set<string> | null {
if (!ids.length) return new Set();
if (!roles) return null;
const out = new Set<string>();
const seen = new Set<string>();
const walk = (id: string): boolean => {
if (seen.has(id)) return true;
seen.add(id);
const role = roles.get(id);
if (!role) return false;
for (const p of Object.keys(role.enabledPermissions ?? {})) out.add(p);
return Object.keys(role.roleIds ?? {}).every(walk);
};
return ids.every(walk) ? out : null;
}
/** Whether the viewer could grant a role: they hold everything it carries. */
export function canGrantRole(viewer: Permissions, roleId: string, roles: ReadonlyMap<string, RoleDef> | null): boolean {
const granted = resolveRoles([roleId], roles);
return granted !== null && [...granted].every((p) => viewer.has(p));
}
/**
* A password to hand to somebody who will change it.
*
* Twenty characters from an alphabet without the ones people misread aloud
* (0/O, 1/l/I), in groups of five. Rejection sampling, so every character is
* equally likely rather than the first few of the alphabet slightly more.
*/
const ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function generatePassword(random: (n: number) => Uint8Array = (n) => crypto.getRandomValues(new Uint8Array(n))): string {
const out: string[] = [];
const limit = 256 - (256 % ALPHABET.length);
while (out.length < 20) {
for (const byte of random(32)) {
if (byte < limit && out.length < 20) out.push(ALPHABET[byte % ALPHABET.length]!);
}
}
return [0, 5, 10, 15].map((i) => out.slice(i, i + 5).join("")).join("-");
}
+128
View File
@@ -0,0 +1,128 @@
import { client, JmapMethodError } from "@/jmap/client";
/**
* The numbers on Administration's dashboard, over the ordinary JMAP proxy.
*
* Counts are queries with `calculateTotal` and `limit: 0`: Stalwart lifts its
* own limit when asked for a total, so the number is the whole count, and no
* ids come back to be thrown away. For a tenant administrator the server scopes
* all three to the tenancy -- accounts and domains to its members, the queue to
* messages touching its domains.
*
* The rest is read from `x:Metric`, the history Stalwart records once per
* collection interval (hourly by default): a Counter holds what happened in
* that interval, a Gauge the reading at its end. Received and sent are the sums
* Stalwart's own dashboard shows, over the same metric names. The history is
* Enterprise-only and has to be switched on (`x:MetricsStore`); a Community
* server refuses the query as `forbidden`, and one that records nothing
* answers with nothing -- the two cases the dashboard tells apart.
*/
export const RECEIVED_METRICS = ["queue.message-queued"] as const;
export const SENT_METRICS = ["queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued"] as const;
export const MEMORY_METRIC = "server.memory";
/** The window received and sent cover. */
export const DASHBOARD_WINDOW_MS = 24 * 60 * 60 * 1000;
export interface MetricRecord {
"@type": "Counter" | "Gauge" | "Histogram";
metric: string;
count: number;
timestamp: string;
sum?: number;
}
export interface MessageStats {
received: number;
sent: number;
/** The latest memory reading, or null when none was recorded in the window. */
memory: { bytes: number; at: string } | null;
/**
* Whether the server recorded anything in the window. Memory is written every
* interval, so a window with no records at all is a history that is switched
* off -- not a quiet day, which would still say zero.
*/
recorded: boolean;
}
export function summarizeMetrics(records: readonly MetricRecord[]): MessageStats {
let received = 0;
let sent = 0;
let memory: MessageStats["memory"] = null;
const receivedNames = new Set<string>(RECEIVED_METRICS);
const sentNames = new Set<string>(SENT_METRICS);
for (const r of records) {
if (r["@type"] === "Counter") {
if (receivedNames.has(r.metric)) received += r.count;
else if (sentNames.has(r.metric)) sent += r.count;
} else if (r["@type"] === "Gauge" && r.metric === MEMORY_METRIC) {
if (!memory || r.timestamp > memory.at) memory = { bytes: r.count, at: r.timestamp };
}
}
return { received, sent, memory, recorded: records.length > 0 };
}
type CountedObject = "Account" | "Domain" | "QueuedMessage";
/** How many there are. Accounts are counted as users: groups are accounts too. */
export async function countObjects(object: CountedObject): Promise<number> {
const res = await client.call<{ total?: number; ids?: string[] }>(`x:${object}/query`, {
...(object === "Account" ? { filter: { "@type": "User" } } : {}),
limit: 0,
calculateTotal: true,
});
return res.total ?? res.ids?.length ?? 0;
}
/**
* Every record of the dashboard's metrics since `since`, newest first.
*
* The filter keys are Stalwart's comparison names for the property -- a bare
* `timestamp` is `unsupportedFilter`. A day at the default hourly interval is
* well under one page; the paging is for a server that collects far more often.
*/
export async function loadMetrics(since: Date): Promise<MetricRecord[]> {
const filter = {
timestampIsGreaterThanOrEqual: since.toISOString().replace(/\.\d{3}Z$/, "Z"),
metric: [...RECEIVED_METRICS, ...SENT_METRICS, MEMORY_METRIC],
};
const step = client.maxObjectsInGet;
const out: MetricRecord[] = [];
for (let position = 0; ; position += step) {
const q = await client.call<{ ids?: string[] }>("x:Metric/query", { filter, sort: [{ property: "timestamp", isAscending: false }], position, limit: step });
const ids = q.ids ?? [];
if (ids.length) out.push(...(await client.call<{ list: MetricRecord[] }>("x:Metric/get", { ids })).list);
if (ids.length < step) return out;
}
}
/**
* How many columns the cards take, so no row is left short.
*
* `wide` is the most that divides the cards evenly without going past four;
* `mid` is what they drop to when that no longer fits, again only a count the
* cards divide into -- three cards go to one column rather than two and one.
* Five is the one count nothing divides, and takes three over two. A phone
* always gets one column, which the stylesheet decides.
*/
export function balancedColumns(cards: number): { wide: number; mid: number } {
switch (cards) {
case 0:
case 1:
return { wide: 1, mid: 1 };
case 2:
return { wide: 2, mid: 2 };
case 3:
return { wide: 3, mid: 1 };
case 4:
return { wide: 4, mid: 2 };
default:
return { wide: 3, mid: 2 };
}
}
/** A refusal from the server itself, as opposed to a failure to reach it. */
export function isRefused(err: unknown): boolean {
return err instanceof JmapMethodError && err.error.type === "forbidden";
}
+326
View File
@@ -0,0 +1,326 @@
import { client } from "@/jmap/client";
import { t } from "@/lib/i18n";
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/admin/adminAccess";
/**
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
*
* 0.16 removed the REST management API (`/api/principal` and the rest); people,
* domains and roles are registry objects now, read and written with `x:Account`,
* `x:Domain` and `x:Role`. These go through `/api/jmap` like every other call,
* authenticated as the signed-in account, so ihasmail holds nothing new: no
* route of its own, no store, no cache beyond the component showing the list.
*
* Shapes, from the 0.16.22 source:
*
* - A list (credentials, aliases) is an object keyed by index, `{"0": …}`. A
* set (memberGroupIds, role ids, permissions) is `{"id": true}`.
* - An account's `name` is its local part, and its domain is a `domainId`.
* `emailAddress` and `usedDiskQuota` are computed by the server.
* - Secrets read back masked. A new password is written to the existing
* password credential, so its id -- which OAuth tokens are tied to -- stays.
* - Filters are AND only, keyed by property name as it appears on the object
* (`@type`, not `type`), and the default order is newest first.
*
* Query and get are two requests rather than one with a result reference.
* Whether the registry methods resolve back-references has not been checked on
* a live server, and a list that loads a moment slower is a better failure than
* one that never loads.
*/
export interface EmailAlias {
enabled?: boolean;
name: string;
domainId: string;
description?: string | null;
}
export interface Credential {
"@type": "Password" | "AppPassword" | "ApiKey";
secret?: string;
description?: string;
}
export interface DirectoryAccount {
id: string;
"@type": "User" | "Group";
name: string;
domainId: string;
emailAddress?: string;
description?: string | null;
roles?: UserRoles;
permissions?: PermissionsMode;
quotas?: Record<string, number>;
usedDiskQuota?: number;
aliases?: Record<string, EmailAlias>;
memberGroupIds?: Record<string, boolean>;
/** The tenant the account belongs to; only ever read back to an administrator outside every tenant. */
memberTenantId?: string | null;
credentials?: Record<string, Credential>;
createdAt?: string;
}
export interface DirectoryDomain {
id: string;
name: string;
/** The tenant the domain is in: an account can be in a tenant only on one of its domains. */
memberTenantId?: string | null;
}
const ACCOUNT_PROPERTIES = [
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
"usedDiskQuota", "aliases", "memberGroupIds", "memberTenantId", "credentials", "createdAt",
];
/** The one quota ihasmail edits; the others keep whatever they had. */
export const DISK_QUOTA = "maxDiskQuota";
/** An error with a SetError behind it, kept so the caller can explain it. */
export class DirectoryError extends Error {
constructor(
readonly type: string,
readonly description: string | undefined,
readonly properties: string[] = [],
) {
super(description ?? type);
this.name = "DirectoryError";
}
}
interface QueryResult {
ids: string[];
total?: number;
position?: number;
}
export async function queryAccounts(opts: { type: "User" | "Group"; text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
// The registry names the discriminator `@type`, as it is on the object. A
// plain `type` is not a property it knows and fails the whole query.
const filter: Record<string, unknown> = { "@type": opts.type };
if (opts.text?.trim()) filter.text = opts.text.trim();
const res = await client.call<QueryResult>("x:Account/query", {
filter,
position: opts.position ?? 0,
...(opts.limit ? { limit: opts.limit } : {}),
calculateTotal: true,
});
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
}
export async function getAccounts(ids: string[]): Promise<DirectoryAccount[]> {
if (!ids.length) return [];
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids, properties: ACCOUNT_PROPERTIES });
// In the order the query gave, which is the order the list is shown in.
const byId = new Map(res.list.map((a) => [a.id, a]));
return ids.map((id) => byId.get(id)).filter((a): a is DirectoryAccount => Boolean(a));
}
/** Every one of a kind, for the pickers. Capped by what the server allows in a get. */
async function all<T>(object: "Domain" | "Role", properties: string[]): Promise<T[]> {
const q = await client.call<QueryResult>(`x:${object}/query`, { limit: client.maxObjectsInGet });
if (!q.ids?.length) return [];
const res = await client.call<{ list: T[] }>(`x:${object}/get`, { ids: q.ids, properties });
return res.list;
}
export const listDomains = () => all<DirectoryDomain>("Domain", ["name", "memberTenantId"]);
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
export async function listGroups(): Promise<DirectoryAccount[]> {
const q = await queryAccounts({ type: "Group", limit: client.maxObjectsInGet });
if (!q.ids.length) return [];
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids: q.ids, properties: ["name", "emailAddress", "description"] });
return res.list;
}
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined>;
function throwIfRefused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
const failure = Object.values(res[kind] ?? {})[0];
if (failure) throw new DirectoryError(failure.type, failure.description, failure.properties);
}
export interface NewAccount {
name: string;
domainId: string;
description: string;
password: string;
roles: UserRoles;
diskQuotaBytes: number | null;
/** Put the account in a tenant; only an administrator outside every tenant may. */
memberTenantId?: string | null;
}
export async function createAccount(input: NewAccount): Promise<string> {
const res = await client.call<SetResponse & { created?: Record<string, { id: string }> }>("x:Account/set", {
create: {
n: {
"@type": "User",
name: input.name.trim(),
domainId: input.domainId,
description: input.description.trim() || null,
credentials: { "0": { "@type": "Password", secret: input.password } },
roles: input.roles,
permissions: { "@type": "Inherit" },
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
aliases: {},
memberGroupIds: {},
...(input.memberTenantId ? { memberTenantId: input.memberTenantId } : {}),
// Required on create. Turning it on is one-way and not offered here.
encryptionAtRest: { "@type": "Disabled" },
},
},
});
throwIfRefused(res, "notCreated");
const id = res.created?.n?.id;
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the account was created."));
return id;
}
export async function updateAccount(id: string, patch: Record<string, unknown>): Promise<void> {
if (!Object.keys(patch).length) return;
const res = await client.call<SetResponse>("x:Account/set", { update: { [id]: patch } });
throwIfRefused(res, "notUpdated");
}
export async function destroyAccount(id: string): Promise<void> {
const res = await client.call<SetResponse>("x:Account/set", { destroy: [id] });
throwIfRefused(res, "notDestroyed");
}
/**
* The patch that sets a new password.
*
* Into the existing password credential when there is one, which keeps its
* credential id; as a new credential after the last index when there is not --
* an account that has only ever signed in through a directory, say. An account
* holds one password at most, so adding a second is never the answer.
*/
export function passwordPatch(account: Pick<DirectoryAccount, "credentials">, secret: string): Record<string, unknown> {
const entries = Object.entries(account.credentials ?? {});
const existing = entries.find(([, c]) => c["@type"] === "Password");
if (existing) return { [`credentials/${existing[0]}/secret`]: secret };
const next = entries.reduce((max, [k]) => Math.max(max, Number(k) + 1), 0);
return { [`credentials/${next}`]: { "@type": "Password", secret } };
}
export function hasPassword(account: Pick<DirectoryAccount, "credentials">): boolean {
return Object.values(account.credentials ?? {}).some((c) => c["@type"] === "Password");
}
/** Re-index a list of aliases the way the server stores them. */
export function aliasList(aliases: EmailAlias[]): Record<string, EmailAlias> {
return Object.fromEntries(aliases.map((a, i) => [String(i), { enabled: a.enabled ?? true, name: a.name, domainId: a.domainId, description: a.description ?? null }]));
}
/** The quotas object with the disk limit set or cleared, and every other quota kept. */
export function quotasWithDisk(quotas: Record<string, number> | undefined, bytes: number | null): Record<string, number> {
const next = { ...(quotas ?? {}) };
if (bytes && bytes > 0) next[DISK_QUOTA] = bytes;
else delete next[DISK_QUOTA];
return next;
}
/**
* The server's own wording for a value one of its validators refused, and
* what to say instead. These come from the registry's string validators
* (`crates/registry/src/types/string.rs`), which is the whole list: anything
* else Stalwart says about a value is picked up by the fallback below.
*/
const VALIDATOR_MESSAGES: Record<string, () => string> = {
"Invalid domain name": () => t("That isn't a valid domain name. Use a name such as example.com, on a real top-level domain."),
"Invalid email address": () => t("That isn't a valid email address. Use a full address, such as [email protected]."),
"Invalid email local part": () => t("That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @."),
"Invalid hostname or IP address": () => t("That isn't a valid host name or IP address."),
"String cannot be empty": () => t("A required value was left empty."),
};
/** What kind of thing a refusal was about, where the wording has to differ. */
export type DirectoryObject = "account" | "domain" | "group" | "list" | "role" | "tenant";
/**
* Say what went wrong in terms of the person's own action, in their language.
*
* Stalwart explains a refusal in English, and its words are never shown as
* they are: an interface in German that answers in English reads as broken
* even when the English is exact. Every type the registry returns has its
* own message, and a value a validator refused is recognized by the
* validator's wording and said again here.
*
* One exception, on purpose. A password policy is the server's to set -- a
* length, a strength -- and there is no way to know its rule in advance to
* translate it, so its reason is kept after a translated sentence. Dropping it
* would leave "not accepted" with no way to find out why.
*/
export function describeDirectoryError(err: unknown, object: DirectoryObject = "account"): string {
if (!(err instanceof DirectoryError)) {
const e = err as { type?: string; code?: string; status?: number };
// ihasmail's own proxy, refusing for this session or this installation.
if (e?.code === "administration_needs_own_device") return t("Only on a device you've marked as your own. Sign in again with “This is my own device” ticked.");
if (e?.code === "administration_disabled") return t("Administration is turned off on this installation.");
if (e?.code === "network_error" || e?.status === 0) return t("Network error. Please check your connection.");
if (e?.code === "rate_limited" || e?.status === 429) return t("Too many attempts. Please wait a few minutes and try again.");
// A method-level JMAP error: the whole call was refused.
if (e?.type === "forbidden") return t("The mail server refused this. Your role may not allow it.");
if (e?.type) return t("The mail server could not carry out the request ({code}).", { code: e.type });
return t("The mail server could not carry out the request ({code}).", { code: e?.code ?? "error" });
}
const description = err.description ?? "";
switch (err.type) {
case "forbidden":
if (/not authorized to grant/i.test(description)) {
return object === "role" ? t("You can't give a role permissions your own role doesn't have.") : t("You can't give an account permissions your own role doesn't have.");
}
if (/external directory/i.test(description)) return t("This account signs in through an external directory, so its password can't be set here.");
if (/licen[cs]ed account limit/i.test(description)) return t("The server's license allows no more accounts.");
return t("The mail server refused this. Your role may not allow it.");
case "primaryKeyViolation":
return object === "domain"
? t("That domain name is already in use on this server, as a domain or another domain's other name.")
: t("That address is already in use on this server, as an account, a list or an alias.");
case "invalidForeignKey":
return t("One of the chosen domain, role or group can't be used for this account.");
case "overQuota":
return object === "domain"
? t("Your organization has reached the number of domains it is allowed.")
: object === "group"
? t("Your organization has reached the number of groups it is allowed.")
: object === "list"
? t("Your organization has reached the number of mailing lists it is allowed.")
: object === "role"
? t("Your organization has reached the number of roles it is allowed.")
: object === "tenant"
? t("The server allows no more tenants.")
: t("Your organization has reached the number of accounts it is allowed.");
case "objectIsLinked":
return t("Something still depends on this, so the server kept it.");
case "notFound":
return object === "domain"
? t("This domain no longer exists. Someone may have removed it.")
: object === "group"
? t("This group no longer exists. Someone may have deleted it.")
: object === "list"
? t("This mailing list no longer exists. Someone may have deleted it.")
: object === "role"
? t("This role no longer exists. Someone may have deleted it.")
: object === "tenant"
? t("This tenant no longer exists. Someone may have deleted it.")
: t("This account no longer exists. Someone may have deleted it.");
case "rateLimit":
return t("Too many attempts. Please wait a few minutes and try again.");
case "tooLarge":
return t("That is more than the mail server accepts in one change.");
case "invalidPatch":
case "invalidProperties":
case "validationFailed": {
if (err.properties.includes("secret")) {
return description ? t("The password was not accepted: {reason}", { reason: description }) : t("The password was not accepted.");
}
const known = VALIDATOR_MESSAGES[description];
if (known) return known();
return t("The mail server rejected one of the values. Check what you entered and try again.");
}
default:
return t("The mail server refused the change ({code}).", { code: err.type });
}
}
+236
View File
@@ -0,0 +1,236 @@
import { client } from "@/jmap/client";
import { plural, t } from "@/lib/i18n";
import { DirectoryError } from "@/lib/admin/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 normalizeDomain(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(normalizeDomain(name));
}
export async function createDomain(input: { name: string; description: string }): Promise<string> {
const res = await client.call<SetResponse>("x:Domain/set", {
create: { n: { name: normalizeDomain(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 serializer writes one record per line as `name IN TYPE value`, and a TXT
* record longer than 255 bytes as a parenthesized 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 if (kind === "Role") parts.push(plural(n, { one: "{n} role", other: "{n} roles" }));
else if (kind === "Domain") parts.push(plural(n, { one: "{n} domain", other: "{n} domains" }));
else if (kind === "Authentication") parts.push(t("the default roles"));
else parts.push(plural(n, { one: "{n} other item", other: "{n} other items" }));
}
return parts.join(", ");
}
+172
View File
@@ -0,0 +1,172 @@
import { client } from "@/jmap/client";
import { t } from "@/lib/i18n";
import type { PermissionsMode, UserRoles } from "@/lib/admin/adminAccess";
import { DirectoryError, DISK_QUOTA, queryAccounts, type EmailAlias } from "@/lib/admin/adminDirectory";
/**
* Groups, from Stalwart 0.16's directory.
*
* A group is not an object of its own: it is an `x:Account` whose `@type` is
* `Group`, read and written with the same methods and the same `sysAccount*`
* permissions as a person. What differs, from the 0.16.22 source:
*
* - **Membership lives on the member.** A group has no list of members; each
* user carries `memberGroupIds`, and a group's members are the users whose set
* names it. Adding or removing one is a patch to that user --
* `memberGroupIds/<group>: true`, or `null` to take it out -- which touches
* nothing else in the set. Groups do not nest: a group has no memberships.
* - **Membership is access, not permission.** A user's permissions come from
* their own roles only. What a group gives its members is whatever has been
* shared with the group -- a mailbox, a calendar.
* - **Roles are `Default` or `Custom`,** not a person's `User`/`Admin`/`Custom`.
* A group has no credentials and cannot sign in.
*/
export type GroupRoles = { "@type": "Default" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
export interface DirectoryGroup {
id: string;
"@type": "Group";
name: string;
domainId: string;
emailAddress?: string;
description?: string | null;
roles?: GroupRoles;
permissions?: PermissionsMode;
quotas?: Record<string, number>;
usedDiskQuota?: number;
aliases?: Record<string, EmailAlias>;
createdAt?: string;
}
/** A member as the group's panel shows them, with what `outranks` and `isSelf` need. */
export interface GroupMember {
id: string;
name: string;
emailAddress?: string;
description?: string | null;
roles?: UserRoles;
permissions?: PermissionsMode;
}
const GROUP_PROPERTIES = ["@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas", "usedDiskQuota", "aliases", "createdAt"];
const MEMBER_PROPERTIES = ["name", "emailAddress", "description", "roles", "permissions"];
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined> & {
created?: Record<string, { id: string }>;
};
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
const first = Object.values(res[key] ?? {})[0];
if (first) throw new DirectoryError(first.type, first.description, first.properties);
}
export const queryGroups = (opts: { text?: string; position?: number; limit?: number }) => queryAccounts({ type: "Group", ...opts });
export async function getGroups(ids: string[]): Promise<DirectoryGroup[]> {
if (!ids.length) return [];
const res = await client.call<{ list: DirectoryGroup[] }>("x:Account/get", { ids, properties: GROUP_PROPERTIES });
const byId = new Map(res.list.map((g) => [g.id, g]));
return ids.map((id) => byId.get(id)).filter((g): g is DirectoryGroup => Boolean(g));
}
/** The filter that finds a group's members: users whose memberships name it. */
export const memberFilter = (groupId: string) => ({ "@type": "User", memberGroupIds: groupId });
/** How many members each group has. A count that fails is left out rather than shown as none. */
export async function countMembers(groupIds: string[]): Promise<Map<string, number>> {
const out = new Map<string, number>();
await Promise.all(
groupIds.map(async (id) => {
try {
const res = await client.call<{ total?: number }>("x:Account/query", { filter: memberFilter(id), limit: 0, calculateTotal: true });
if (typeof res.total === "number") out.set(id, res.total);
} catch {
/* the column shows a dash */
}
}),
);
return out;
}
/** A group's members, as many as one get allows, newest first as the server orders them. */
export async function listMembers(groupId: string): Promise<{ members: GroupMember[]; total: number }> {
const q = await client.call<{ ids?: string[]; total?: number }>("x:Account/query", { filter: memberFilter(groupId), limit: client.maxObjectsInGet, calculateTotal: true });
const ids = q.ids ?? [];
if (!ids.length) return { members: [], total: q.total ?? 0 };
const res = await client.call<{ list: GroupMember[] }>("x:Account/get", { ids, properties: MEMBER_PROPERTIES });
return { members: res.list, total: q.total ?? res.list.length };
}
/** People to offer when adding a member, by name or address. */
export async function searchUsers(text: string, limit = 8): Promise<GroupMember[]> {
const q = await queryAccounts({ type: "User", text, limit });
if (!q.ids.length) return [];
const res = await client.call<{ list: GroupMember[] }>("x:Account/get", { ids: q.ids, properties: MEMBER_PROPERTIES });
return res.list;
}
export interface NewGroup {
name: string;
domainId: string;
description: string;
roles: GroupRoles;
diskQuotaBytes: number | null;
}
export async function createGroup(input: NewGroup): Promise<string> {
const res = await client.call<SetResponse>("x:Account/set", {
create: {
n: {
"@type": "Group",
name: input.name.trim(),
domainId: input.domainId,
description: input.description.trim() || null,
roles: input.roles,
permissions: { "@type": "Inherit" },
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
aliases: {},
},
},
});
throwIfRefused(res, "notCreated");
const id = res.created?.n?.id;
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the group was created."));
return id;
}
/** The patch that puts users into a group or takes them out, one pointer each so no other membership moves. */
export function membershipPatch(userIds: readonly string[], groupId: string, member: boolean): Record<string, Record<string, true | null>> {
return Object.fromEntries(userIds.map((id) => [id, { [`memberGroupIds/${groupId}`]: member ? true : null }]));
}
export async function setMembership(userIds: readonly string[], groupId: string, member: boolean): Promise<void> {
if (!userIds.length) return;
const res = await client.call<SetResponse>("x:Account/set", { update: membershipPatch(userIds, groupId, member) });
throwIfRefused(res, "notUpdated");
}
/**
* Delete a group, taking its members out of it first.
*
* Stalwart keeps an object that others still name, and every member's
* `memberGroupIds` names the group -- the same reason a domain's keys go
* before the domain. The two are separate calls: if the memberships cannot be
* changed, nothing has been deleted.
*/
export async function destroyGroup(groupId: string, memberIds: readonly string[]): Promise<void> {
await setMembership(memberIds, groupId, false);
const res = await client.call<SetResponse>("x:Account/set", { destroy: [groupId] });
throwIfRefused(res, "notDestroyed");
}
/** A group's roles as one select value: "Default", or "custom:<ids>". */
export function groupRoleKey(roles: GroupRoles | undefined): string {
if (!roles || roles["@type"] === "Default") return "Default";
return `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`;
}
export function groupRolesFromKey(key: string): GroupRoles {
if (key.startsWith("custom:")) return { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) };
return { "@type": "Default" };
}
+141
View File
@@ -0,0 +1,141 @@
import { client } from "@/jmap/client";
import { t } from "@/lib/i18n";
import { DirectoryError, type EmailAlias } from "@/lib/admin/adminDirectory";
/**
* Mailing lists, from Stalwart 0.16's directory.
*
* A list is its own registry object, `x:MailingList`, behind `sysMailingList*`.
* It is an address and the addresses it passes mail on to, and nothing more:
* there are no owners, no moderation and no posting policy to set. Shapes, as
* the live server answered them (2026-09-15):
*
* - `recipients` is a set of addresses, `{"[email protected]": true}`, on this
* server or anywhere else. One is added with `recipients/<address>: true` and
* taken out with `null`, which leaves the rest of the set alone.
* - `emailAddress` is computed from `name` and `domainId`, as an account's is.
* - The query filters on `text`; the default order is newest first.
*/
export interface DirectoryList {
id: string;
name: string;
domainId: string;
emailAddress?: string;
description?: string | null;
recipients?: Record<string, boolean>;
aliases?: Record<string, EmailAlias>;
}
const LIST_PROPERTIES = ["name", "domainId", "emailAddress", "description", "recipients", "aliases"];
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined> & {
created?: Record<string, { id: string }>;
};
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
const first = Object.values(res[key] ?? {})[0];
if (first) throw new DirectoryError(first.type, first.description, first.properties);
}
export async function queryLists(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
const res = await client.call<{ ids?: string[]; total?: number }>("x:MailingList/query", {
...(opts.text?.trim() ? { filter: { text: opts.text.trim() } } : {}),
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 getLists(ids: string[]): Promise<DirectoryList[]> {
if (!ids.length) return [];
const res = await client.call<{ list: DirectoryList[] }>("x:MailingList/get", { ids, properties: LIST_PROPERTIES });
const byId = new Map(res.list.map((l) => [l.id, l]));
return ids.map((id) => byId.get(id)).filter((l): l is DirectoryList => Boolean(l));
}
export interface NewList {
name: string;
domainId: string;
description: string;
recipients: string[];
}
export async function createList(input: NewList): Promise<string> {
const res = await client.call<SetResponse>("x:MailingList/set", {
create: {
n: {
name: input.name.trim(),
domainId: input.domainId,
description: input.description.trim() || null,
recipients: Object.fromEntries(input.recipients.map((r) => [r, true])),
aliases: {},
},
},
});
throwIfRefused(res, "notCreated");
const id = res.created?.n?.id;
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the list was created."));
return id;
}
export async function updateList(id: string, patch: Record<string, unknown>): Promise<void> {
if (!Object.keys(patch).length) return;
const res = await client.call<SetResponse>("x:MailingList/set", { update: { [id]: patch } });
throwIfRefused(res, "notUpdated");
}
export async function destroyList(id: string): Promise<void> {
const res = await client.call<SetResponse>("x:MailingList/set", { destroy: [id] });
throwIfRefused(res, "notDestroyed");
}
/** An address as one step of a JSON pointer: `~` and `/` escaped, as RFC 6901 has it. */
const pointerKey = (address: string) => address.replace(/~/g, "~0").replace(/\//g, "~1");
/**
* The recipient changes between two lists of addresses, one pointer each.
*
* Only what changed is sent, so a recipient someone else added while this panel
* was open is not taken out by saving it. Addresses compare without regard to
* case, the way mail is delivered to them.
*/
export function recipientsPatch(before: readonly string[], after: readonly string[]): Record<string, true | null> {
const lower = (list: readonly string[]) => new Map(list.map((a) => [a.toLowerCase(), a]));
const was = lower(before);
const now = lower(after);
const patch: Record<string, true | null> = {};
for (const [key, address] of was) if (!now.has(key)) patch[`recipients/${pointerKey(address)}`] = null;
for (const [key, address] of now) if (!was.has(key)) patch[`recipients/${pointerKey(address)}`] = true;
return patch;
}
/** A plausible address: one @, something either side, a dot in the domain. Stalwart has the last word. */
export function looksLikeAddress(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
/**
* Addresses out of whatever was typed or pasted: a line from a spreadsheet, a
* list separated by commas, `Name <address>`. Words with no @ in them are the
* names around the addresses and are passed over; something with an @ that is
* not an address is returned, so it can be shown rather than dropped.
*/
export function parseAddresses(text: string): { addresses: string[]; rejected: string[] } {
const addresses: string[] = [];
const rejected: string[] = [];
const seen = new Set<string>();
for (const raw of text.split(/[\s,;]+/)) {
const token = raw.replace(/^["'(<]+|[>"')]+$/g, "").replace(/^mailto:/i, "");
if (!token.includes("@")) continue;
if (!looksLikeAddress(token)) {
rejected.push(token);
continue;
}
if (seen.has(token.toLowerCase())) continue;
seen.add(token.toLowerCase());
addresses.push(token);
}
return { addresses, rejected };
}
+180
View File
@@ -0,0 +1,180 @@
import { apiFetch, client } from "@/jmap/client";
import { t } from "@/lib/i18n";
import type { Permissions, RoleDef } from "@/lib/admin/adminAccess";
import { DirectoryError } from "@/lib/admin/adminDirectory";
import { DomainError } from "@/lib/admin/adminDomains";
import type { PermissionInfo } from "@/lib/permissionLabels";
/**
* Roles, from Stalwart 0.16's directory.
*
* `x:Role` behind `sysRole*`. A role has a `description` -- which is its name;
* there is no other -- the roles it builds on (`roleIds`, followed all the way
* down), and two sets of permissions: `enabledPermissions` it adds and
* `disabledPermissions` it takes away, which wins over anything enabled or
* inherited. The four a new server starts with (User, Group, Tenant
* Administrator, System Administrator) are ordinary rows, editable like any
* other, written once at first boot.
*
* Stalwart refuses to create or change a role that would carry a permission
* the caller does not hold, which is what the picker's locked rows show ahead
* of time. It does not check a delete.
*/
export interface DirectoryRole extends RoleDef {
description?: string | null;
enabledPermissions?: Record<string, boolean>;
disabledPermissions?: Record<string, boolean>;
roleIds?: Record<string, boolean>;
}
/** Which roles Stalwart gives an account that has been given none of its own. */
export interface RoleDefaults {
user: string[];
group: string[];
tenant: string[];
admin: string[];
}
const ROLE_PROPERTIES = ["description", "enabledPermissions", "disabledPermissions", "roleIds"];
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined> & {
created?: Record<string, { id: string }>;
};
/** A refusal, carrying what the server says still uses the role -- a delete's usual answer. */
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
const first = Object.values(res[key] ?? {})[0] as ({ type: string; description?: string; properties?: string[]; linkedObjects?: Array<{ object?: string; id?: string }> } | null | undefined);
if (first) throw new DomainError(first);
}
/** Every role, sorted by name. There are few enough to hold at once; the server caps a get anyway. */
export async function listAllRoles(): Promise<DirectoryRole[]> {
const q = await client.call<{ ids?: string[] }>("x:Role/query", { limit: client.maxObjectsInGet });
if (!q.ids?.length) return [];
const res = await client.call<{ list: DirectoryRole[] }>("x:Role/get", { ids: q.ids, properties: ROLE_PROPERTIES });
return res.list.sort((a, b) => (a.description ?? a.id).localeCompare(b.description ?? b.id));
}
/** The default roles, or null when the viewer may not read the authentication settings. */
export async function loadRoleDefaults(): Promise<RoleDefaults | null> {
try {
const res = await client.call<{ list: Array<Record<string, Record<string, boolean> | undefined>> }>("x:Authentication/get", {
ids: ["singleton"],
properties: ["defaultUserRoleIds", "defaultGroupRoleIds", "defaultTenantRoleIds", "defaultAdminRoleIds"],
});
const s = res.list[0];
if (!s) return null;
const ids = (k: string) => Object.keys(s[k] ?? {});
return { user: ids("defaultUserRoleIds"), group: ids("defaultGroupRoleIds"), tenant: ids("defaultTenantRoleIds"), admin: ids("defaultAdminRoleIds") };
} catch {
return null;
}
}
/** Stalwart's labeled permission list, through ihasmail's server. */
export async function loadPermissionList(): Promise<PermissionInfo[]> {
const res = await apiFetch<{ permissions: PermissionInfo[] }>("/api/admin/permissions");
return res.permissions;
}
export interface NewRole {
description: string;
roleIds: string[];
enabled: string[];
disabled: string[];
}
const set = (names: readonly string[]) => Object.fromEntries(names.map((n) => [n, true]));
export async function createRole(input: NewRole): Promise<string> {
const res = await client.call<SetResponse>("x:Role/set", {
create: { n: { description: input.description.trim(), roleIds: set(input.roleIds), enabledPermissions: set(input.enabled), disabledPermissions: set(input.disabled) } },
});
throwIfRefused(res, "notCreated");
const id = res.created?.n?.id;
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the role was created."));
return id;
}
export async function updateRole(id: string, patch: Record<string, unknown>): Promise<void> {
if (!Object.keys(patch).length) return;
const res = await client.call<SetResponse>("x:Role/set", { update: { [id]: patch } });
throwIfRefused(res, "notUpdated");
}
export async function destroyRole(id: string): Promise<void> {
const res = await client.call<SetResponse>("x:Role/set", { destroy: [id] });
throwIfRefused(res, "notDestroyed");
}
/** A set property's changes as one pointer per name, so nothing else in the set is touched. */
export function setPatch(property: string, before: Iterable<string>, after: Iterable<string>): Record<string, true | null> {
const was = new Set(before);
const now = new Set(after);
const patch: Record<string, true | null> = {};
for (const n of was) if (!now.has(n)) patch[`${property}/${n}`] = null;
for (const n of now) if (!was.has(n)) patch[`${property}/${n}`] = true;
return patch;
}
/** What a permission is, on the role being edited. */
export type PermissionState = "allow" | "deny" | "none";
/**
* What a role's bases grant and take away, and which base each came through.
*
* Stalwart unions every role in the tree -- enabled with enabled, disabled with
* disabled -- and then takes the disabled set away (`permissions.rs`), so a
* denial on a base role holds on every role built on it.
*/
export function inherited(roleIds: readonly string[], roles: ReadonlyMap<string, DirectoryRole>, exclude?: string): { granted: Map<string, string>; denied: Map<string, string> } {
const granted = new Map<string, string>();
const denied = new Map<string, string>();
const seen = new Set<string>(exclude ? [exclude] : []);
const walk = (id: string, via: string) => {
if (seen.has(id)) return;
seen.add(id);
const role = roles.get(id);
if (!role) return;
for (const p of Object.keys(role.enabledPermissions ?? {})) if (!granted.has(p)) granted.set(p, via);
for (const p of Object.keys(role.disabledPermissions ?? {})) if (!denied.has(p)) denied.set(p, via);
for (const child of Object.keys(role.roleIds ?? {})) walk(child, via);
};
for (const id of roleIds) walk(id, id);
return { granted, denied };
}
/** The roles a role may build on: not itself, and none that already builds on it. */
export function canBuildOn(roleId: string | null, candidate: string, roles: ReadonlyMap<string, DirectoryRole>): boolean {
if (!roleId) return roles.has(candidate);
if (candidate === roleId) return false;
const seen = new Set<string>();
const reaches = (id: string): boolean => {
if (id === roleId) return true;
if (seen.has(id)) return false;
seen.add(id);
return Object.keys(roles.get(id)?.roleIds ?? {}).some(reaches);
};
return !reaches(candidate);
}
/** Everything a role grants once its bases are followed and every denial in the tree taken away. */
export function effectivePermissions(role: Pick<DirectoryRole, "enabledPermissions" | "disabledPermissions" | "roleIds">, roles: ReadonlyMap<string, DirectoryRole>, self?: string): Set<string> {
const base = inherited(Object.keys(role.roleIds ?? {}), roles, self);
const out = new Set<string>([...base.granted.keys(), ...Object.keys(role.enabledPermissions ?? {})]);
for (const p of [...base.denied.keys(), ...Object.keys(role.disabledPermissions ?? {})]) out.delete(p);
return out;
}
/**
* Whether a role carries a permission the viewer does not hold, which makes it
* read-only to them. Everything enabled anywhere in its tree counts, denied or
* not: that is what Stalwart checks a grant against, and what a delete -- which
* it does not check -- would otherwise let someone take away.
*/
export function roleOutranks(viewer: Permissions, role: DirectoryRole, roles: ReadonlyMap<string, DirectoryRole>): boolean {
const granted = new Set<string>([...inherited(Object.keys(role.roleIds ?? {}), roles, role.id).granted.keys(), ...Object.keys(role.enabledPermissions ?? {})]);
for (const p of granted) if (!viewer.has(p)) return true;
return false;
}
+188
View File
@@ -0,0 +1,188 @@
import { client } from "@/jmap/client";
import { t } from "@/lib/i18n";
import { DirectoryError } from "@/lib/admin/adminDirectory";
import { DomainError } from "@/lib/admin/adminDomains";
/**
* Tenants, from Stalwart 0.16's directory.
*
* `x:Tenant` behind `sysTenant*`, an Enterprise feature: on a Community server
* the objects exist, but anyone inside a tenant is held to a plain user's
* permissions. A tenant is a name, an optional logo, the roles its members may
* at most have, and quotas. It holds no list of what is in it -- membership
* runs the other way, as `memberTenantId` on accounts, groups, domains,
* mailing lists, roles and DKIM keys.
*
* Only an account outside every tenant may set `memberTenantId` (Stalwart
* refuses "Cannot modify memberTenantId property" to anyone else), and inside a
* tenant the server scopes every query to it and fills it in on create. Shapes
* from the 0.16.22 schema:
*
* - `quotas` is a map from a `TenantStorageQuota` name to a number: counts for
* accounts, groups, domains and the rest, bytes for `maxDiskQuota`. A quota
* that is absent is no limit.
* - `logo` is a URL or a data URL, or null.
*/
export type TenantRoles = { "@type": "Default" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
export interface DirectoryTenant {
id: string;
name: string;
logo?: string | null;
roles?: TenantRoles;
quotas?: Record<string, number>;
usedDiskQuota?: number;
createdAt?: string;
}
/** The quotas ihasmail offers, in the order they are shown. Disk space is bytes; the rest are counts. */
export const TENANT_QUOTAS = ["maxAccounts", "maxGroups", "maxMailingLists", "maxDomains", "maxRoles", "maxDkimKeys", "maxDiskQuota"] as const;
export type TenantQuota = (typeof TENANT_QUOTAS)[number];
/** What belongs to a tenant, and how each is counted. */
export const TENANT_MEMBERS = [
{ key: "accounts", method: "x:Account/query", filter: { "@type": "User" }, quota: "maxAccounts" },
{ key: "groups", method: "x:Account/query", filter: { "@type": "Group" }, quota: "maxGroups" },
{ key: "lists", method: "x:MailingList/query", filter: {}, quota: "maxMailingLists" },
{ key: "domains", method: "x:Domain/query", filter: {}, quota: "maxDomains" },
{ key: "roles", method: "x:Role/query", filter: {}, quota: "maxRoles" },
// A domain's keys join the tenant it was created in, and keep it there.
{ key: "dkimKeys", method: "x:DkimSignature/query", filter: {}, quota: "maxDkimKeys" },
] as const;
export type TenantMemberKind = (typeof TENANT_MEMBERS)[number]["key"];
const TENANT_PROPERTIES = ["name", "logo", "roles", "quotas", "usedDiskQuota", "createdAt"];
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[]; linkedObjects?: Array<{ object?: string; id?: string }> } | null> | undefined> & {
created?: Record<string, { id: string }>;
};
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
const first = Object.values(res[key] ?? {})[0];
if (first) throw new DomainError(first);
}
export async function queryTenants(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
const res = await client.call<{ ids?: string[]; total?: number }>("x:Tenant/query", {
...(opts.text?.trim() ? { filter: { text: opts.text.trim() } } : {}),
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 getTenants(ids: string[]): Promise<DirectoryTenant[]> {
if (!ids.length) return [];
const res = await client.call<{ list: DirectoryTenant[] }>("x:Tenant/get", { ids, properties: TENANT_PROPERTIES });
const byId = new Map(res.list.map((x) => [x.id, x]));
return ids.map((id) => byId.get(id)).filter((x): x is DirectoryTenant => Boolean(x));
}
/** Every tenant's id and name, for pickers. */
export async function listTenantNames(): Promise<Array<{ id: string; name: string }>> {
const q = await client.call<{ ids?: string[] }>("x:Tenant/query", { limit: client.maxObjectsInGet });
if (!q.ids?.length) return [];
const res = await client.call<{ list: Array<{ id: string; name: string }> }>("x:Tenant/get", { ids: q.ids, properties: ["name"] });
return res.list.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* How many of each kind of thing a tenant holds. A count that fails -- the
* viewer may not read that kind at all -- is left out rather than shown as
* none, which would read as "safe to delete".
*/
export async function countTenantMembers(tenantId: string): Promise<Partial<Record<TenantMemberKind, number>>> {
const out: Partial<Record<TenantMemberKind, number>> = {};
await Promise.all(
TENANT_MEMBERS.map(async (m) => {
try {
const res = await client.call<{ total?: number }>(m.method, { filter: { ...m.filter, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
if (typeof res.total === "number") out[m.key] = res.total;
} catch {
/* left out */
}
}),
);
return out;
}
/** The domains in a tenant, and those in none, which are the ones that can be added. */
export async function tenantDomains(tenantId: string): Promise<{ inTenant: Array<{ id: string; name: string }>; unassigned: Array<{ id: string; name: string }> }> {
const q = await client.call<{ ids?: string[] }>("x:Domain/query", { limit: client.maxObjectsInGet });
if (!q.ids?.length) return { inTenant: [], unassigned: [] };
const res = await client.call<{ list: Array<{ id: string; name: string; memberTenantId?: string | null }> }>("x:Domain/get", { ids: q.ids, properties: ["name", "memberTenantId"] });
const sorted = res.list.sort((a, b) => a.name.localeCompare(b.name));
return {
inTenant: sorted.filter((d) => d.memberTenantId === tenantId).map(({ id, name }) => ({ id, name })),
unassigned: sorted.filter((d) => !d.memberTenantId).map(({ id, name }) => ({ id, name })),
};
}
/**
* How many of a tenant's accounts and groups are on a domain.
*
* Stalwart lets a domain leave a tenant while the tenant still has accounts on
* it (live, 2026-09-15), leaving them in a tenant on a domain outside it --
* which it refuses to create. The panel asks this before it offers the move.
*/
export async function tenantAccountsOnDomain(tenantId: string, domainId: string): Promise<number> {
const res = await client.call<{ total?: number }>("x:Account/query", { filter: { domainId, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
return res.total ?? 0;
}
/** Put a domain in a tenant, or take it out with null. */
export async function setDomainTenant(domainId: string, tenantId: string | null): Promise<void> {
const res = await client.call<SetResponse>("x:Domain/set", { update: { [domainId]: { memberTenantId: tenantId } } });
throwIfRefused(res, "notUpdated");
}
export interface NewTenant {
name: string;
logo: string | null;
roles: TenantRoles;
quotas: Record<string, number>;
}
export async function createTenant(input: NewTenant): Promise<string> {
const res = await client.call<SetResponse>("x:Tenant/set", {
create: { n: { name: input.name.trim(), logo: input.logo, roles: input.roles, permissions: { "@type": "Inherit" }, quotas: input.quotas } },
});
throwIfRefused(res, "notCreated");
const id = res.created?.n?.id;
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the tenant was created."));
return id;
}
export async function updateTenant(id: string, patch: Record<string, unknown>): Promise<void> {
if (!Object.keys(patch).length) return;
const res = await client.call<SetResponse>("x:Tenant/set", { update: { [id]: patch } });
throwIfRefused(res, "notUpdated");
}
export async function destroyTenant(id: string): Promise<void> {
const res = await client.call<SetResponse>("x:Tenant/set", { destroy: [id] });
throwIfRefused(res, "notDestroyed");
}
/**
* The quota changes as one pointer each, so a quota ihasmail does not offer
* (OAuth clients, DNS servers, directories, ACME providers) keeps its value.
*/
export function quotasPatch(before: Record<string, number> | undefined, after: Partial<Record<TenantQuota, number | null>>): Record<string, number | null> {
const patch: Record<string, number | null> = {};
for (const key of TENANT_QUOTAS) {
if (!(key in after)) continue;
const next = after[key] ?? null;
const was = before?.[key] ?? null;
if (next !== was) patch[`quotas/${key}`] = next;
}
return patch;
}
/** A logo worth showing: an https or data image URL. Anything else is kept but not drawn. */
export function drawableLogo(logo: string | null | undefined): string | null {
if (!logo) return null;
return /^https:\/\//i.test(logo) || /^data:image\/(png|jpe?g|gif|webp|svg\+xml);/i.test(logo) ? logo : null;
}