Files
inbuxa-webmail/web/src/lib/adminDirectory.ts
T
jcoffey-dev e2a531b615 Add Groups to Administration
A group is a shared address and mailbox and the people who share it. To
Stalwart it is an x:Account of type Group, behind the same sysAccount*
permissions as a person, so it sits under Directory beside Accounts:
search, a page of fifty with each group's member count, and a panel to
create, edit and delete one.

Membership lives on the member, not the group. Members are the users whose
memberGroupIds name it, and adding or removing one is a single
memberGroupIds/<group> pointer on that user's account -- true or null --
which leaves their other groups alone. Changes apply straight away rather
than riding on Save, so the list is always what the server has. Nobody can
add or remove themselves, the same line the account panel draws at one's
own role.

A group's role is Default or Custom, not a person's User or Admin, and it
is what the group may do: in 0.16 a user's permissions come from their own
roles only, and a group gives its members what is shared with it. Only
roles the viewer could grant are offered.

Delete takes the members out first and then deletes the group, the order
a domain's keys go before the domain, because the registry keeps anything
another object names. A role that cannot change the members' accounts is
not offered a delete it could only half finish.

The mock's groups had a person's roles, accepted a memberGroupIds filter
without applying it, and answered a linked delete with the wrong shape;
all three follow the source now, and it refuses nested groups and
memberships of things that are not groups.

Nothing about groups has been run against a live server yet: production
has none, and every operation is a write. KNOWN-ISSUES says what was read
from source.

Thirty-five new strings, two of them plurals, in all nine catalogues.
2026-09-15 08:31:36 -07:00

306 lines
14 KiB
TypeScript

import { client } from "@/jmap/client";
import { t } from "@/lib/i18n";
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/adminAccess";
/**
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
*
* 0.16 removed the REST management API (`/api/principal` and the rest); people,
* domains and roles are registry objects now, read and written with `x:Account`,
* `x:Domain` and `x:Role`. These go through `/api/jmap` like every other call,
* authenticated as the signed-in account, so ihasmail holds nothing new: no
* route of its own, no store, no cache beyond the component showing the list.
*
* Shapes, from the 0.16.22 source:
*
* - A list (credentials, aliases) is an object keyed by index, `{"0": …}`. A
* set (memberGroupIds, role ids, permissions) is `{"id": true}`.
* - An account's `name` is its local part, and its domain is a `domainId`.
* `emailAddress` and `usedDiskQuota` are computed by the server.
* - Secrets read back masked. A new password is written to the existing
* password credential, so its id -- which OAuth tokens are tied to -- stays.
* - Filters are AND only, keyed by property name as it appears on the object
* (`@type`, not `type`), and the default order is newest first.
*
* Query and get are two requests rather than one with a result reference.
* Whether the registry methods resolve back-references has not been checked on
* a live server, and a list that loads a moment slower is a better failure than
* one that never loads.
*/
export interface EmailAlias {
enabled?: boolean;
name: string;
domainId: string;
description?: string | null;
}
export interface Credential {
"@type": "Password" | "AppPassword" | "ApiKey";
secret?: string;
description?: string;
}
export interface DirectoryAccount {
id: string;
"@type": "User" | "Group";
name: string;
domainId: string;
emailAddress?: string;
description?: string | null;
roles?: UserRoles;
permissions?: PermissionsMode;
quotas?: Record<string, number>;
usedDiskQuota?: number;
aliases?: Record<string, EmailAlias>;
memberGroupIds?: Record<string, boolean>;
credentials?: Record<string, Credential>;
createdAt?: string;
}
export interface DirectoryDomain {
id: string;
name: string;
}
const ACCOUNT_PROPERTIES = [
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
"usedDiskQuota", "aliases", "memberGroupIds", "credentials", "createdAt",
];
/** The one quota ihasmail edits; the others keep whatever they had. */
export const DISK_QUOTA = "maxDiskQuota";
/** An error with a SetError behind it, kept so the caller can explain it. */
export class DirectoryError extends Error {
constructor(
readonly type: string,
readonly description: string | undefined,
readonly properties: string[] = [],
) {
super(description ?? type);
this.name = "DirectoryError";
}
}
interface QueryResult {
ids: string[];
total?: number;
position?: number;
}
export async function queryAccounts(opts: { type: "User" | "Group"; text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
// The registry names the discriminator `@type`, as it is on the object. A
// plain `type` is not a property it knows and fails the whole query.
const filter: Record<string, unknown> = { "@type": opts.type };
if (opts.text?.trim()) filter.text = opts.text.trim();
const res = await client.call<QueryResult>("x:Account/query", {
filter,
position: opts.position ?? 0,
...(opts.limit ? { limit: opts.limit } : {}),
calculateTotal: true,
});
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
}
export async function getAccounts(ids: string[]): Promise<DirectoryAccount[]> {
if (!ids.length) return [];
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids, properties: ACCOUNT_PROPERTIES });
// In the order the query gave, which is the order the list is shown in.
const byId = new Map(res.list.map((a) => [a.id, a]));
return ids.map((id) => byId.get(id)).filter((a): a is DirectoryAccount => Boolean(a));
}
/** Every one of a kind, for the pickers. Capped by what the server allows in a get. */
async function all<T>(object: "Domain" | "Role", properties: string[]): Promise<T[]> {
const q = await client.call<QueryResult>(`x:${object}/query`, { limit: client.maxObjectsInGet });
if (!q.ids?.length) return [];
const res = await client.call<{ list: T[] }>(`x:${object}/get`, { ids: q.ids, properties });
return res.list;
}
export const listDomains = () => all<DirectoryDomain>("Domain", ["name"]);
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
export async function listGroups(): Promise<DirectoryAccount[]> {
const q = await queryAccounts({ type: "Group", limit: client.maxObjectsInGet });
if (!q.ids.length) return [];
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids: q.ids, properties: ["name", "emailAddress", "description"] });
return res.list;
}
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined>;
function throwIfRefused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
const failure = Object.values(res[kind] ?? {})[0];
if (failure) throw new DirectoryError(failure.type, failure.description, failure.properties);
}
export interface NewAccount {
name: string;
domainId: string;
description: string;
password: string;
roles: UserRoles;
diskQuotaBytes: number | null;
}
export async function createAccount(input: NewAccount): Promise<string> {
const res = await client.call<SetResponse & { created?: Record<string, { id: string }> }>("x:Account/set", {
create: {
n: {
"@type": "User",
name: input.name.trim(),
domainId: input.domainId,
description: input.description.trim() || null,
credentials: { "0": { "@type": "Password", secret: input.password } },
roles: input.roles,
permissions: { "@type": "Inherit" },
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
aliases: {},
memberGroupIds: {},
// Required on create. Turning it on is one-way and not offered here.
encryptionAtRest: { "@type": "Disabled" },
},
},
});
throwIfRefused(res, "notCreated");
const id = res.created?.n?.id;
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the account was created."));
return id;
}
export async function updateAccount(id: string, patch: Record<string, unknown>): Promise<void> {
if (!Object.keys(patch).length) return;
const res = await client.call<SetResponse>("x:Account/set", { update: { [id]: patch } });
throwIfRefused(res, "notUpdated");
}
export async function destroyAccount(id: string): Promise<void> {
const res = await client.call<SetResponse>("x:Account/set", { destroy: [id] });
throwIfRefused(res, "notDestroyed");
}
/**
* The patch that sets a new password.
*
* Into the existing password credential when there is one, which keeps its
* credential id; as a new credential after the last index when there is not --
* an account that has only ever signed in through a directory, say. An account
* holds one password at most, so adding a second is never the answer.
*/
export function passwordPatch(account: Pick<DirectoryAccount, "credentials">, secret: string): Record<string, unknown> {
const entries = Object.entries(account.credentials ?? {});
const existing = entries.find(([, c]) => c["@type"] === "Password");
if (existing) return { [`credentials/${existing[0]}/secret`]: secret };
const next = entries.reduce((max, [k]) => Math.max(max, Number(k) + 1), 0);
return { [`credentials/${next}`]: { "@type": "Password", secret } };
}
export function hasPassword(account: Pick<DirectoryAccount, "credentials">): boolean {
return Object.values(account.credentials ?? {}).some((c) => c["@type"] === "Password");
}
/** Re-index a list of aliases the way the server stores them. */
export function aliasList(aliases: EmailAlias[]): Record<string, EmailAlias> {
return Object.fromEntries(aliases.map((a, i) => [String(i), { enabled: a.enabled ?? true, name: a.name, domainId: a.domainId, description: a.description ?? null }]));
}
/** The quotas object with the disk limit set or cleared, and every other quota kept. */
export function quotasWithDisk(quotas: Record<string, number> | undefined, bytes: number | null): Record<string, number> {
const next = { ...(quotas ?? {}) };
if (bytes && bytes > 0) next[DISK_QUOTA] = bytes;
else delete next[DISK_QUOTA];
return next;
}
/**
* The server's own wording for a value one of its validators refused, and
* what to say instead. These come from the registry's string validators
* (`crates/registry/src/types/string.rs`), which is the whole list: anything
* else Stalwart says about a value is picked up by the fallback below.
*/
const VALIDATOR_MESSAGES: Record<string, () => string> = {
"Invalid domain name": () => t("That isn't a valid domain name. Use a name such as example.com, on a real top-level domain."),
"Invalid email address": () => t("That isn't a valid email address. Use a full address, such as [email protected]."),
"Invalid email local part": () => t("That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @."),
"Invalid hostname or IP address": () => t("That isn't a valid host name or IP address."),
"String cannot be empty": () => t("A required value was left empty."),
};
/** What kind of thing a refusal was about, where the wording has to differ. */
export type DirectoryObject = "account" | "domain" | "group";
/**
* Say what went wrong in terms of the person's own action, in their language.
*
* Stalwart explains a refusal in English, and its words are never shown as
* they are: an interface in German that answers in English reads as broken
* even when the English is exact. Every type the registry returns has its
* own message, and a value a validator refused is recognised by the
* validator's wording and said again here.
*
* One exception, on purpose. A password policy is the server's to set -- a
* length, a strength -- and there is no way to know its rule in advance to
* translate it, so its reason is kept after a translated sentence. Dropping it
* would leave "not accepted" with no way to find out why.
*/
export function describeDirectoryError(err: unknown, object: DirectoryObject = "account"): string {
if (!(err instanceof DirectoryError)) {
const e = err as { type?: string; code?: string; status?: number };
// ihasmail's own proxy, refusing for this session or this installation.
if (e?.code === "administration_needs_own_device") return t("Only on a device you've marked as your own. Sign in again with “This is my own device” ticked.");
if (e?.code === "administration_disabled") return t("Administration is turned off on this installation.");
if (e?.code === "network_error" || e?.status === 0) return t("Network error. Please check your connection.");
if (e?.code === "rate_limited" || e?.status === 429) return t("Too many attempts. Please wait a few minutes and try again.");
// A method-level JMAP error: the whole call was refused.
if (e?.type === "forbidden") return t("The mail server refused this. Your role may not allow it.");
if (e?.type) return t("The mail server could not carry out the request ({code}).", { code: e.type });
return t("The mail server could not carry out the request ({code}).", { code: e?.code ?? "error" });
}
const description = err.description ?? "";
switch (err.type) {
case "forbidden":
if (/not authorized to grant/i.test(description)) return t("You can't give an account permissions your own role doesn't have.");
if (/external directory/i.test(description)) return t("This account signs in through an external directory, so its password can't be set here.");
if (/licen[cs]ed account limit/i.test(description)) return t("The server's licence allows no more accounts.");
return t("The mail server refused this. Your role may not allow it.");
case "primaryKeyViolation":
return object === "domain"
? t("That domain name is already in use on this server, as a domain or another domain's other name.")
: t("That address is already in use on this server, as an account, a list or an alias.");
case "invalidForeignKey":
return t("One of the chosen domain, role or group can't be used for this account.");
case "overQuota":
return object === "domain"
? t("Your organisation has reached the number of domains it is allowed.")
: object === "group"
? t("Your organisation has reached the number of groups it is allowed.")
: t("Your organisation has reached the number of accounts it is allowed.");
case "objectIsLinked":
return t("Something still depends on this, so the server kept it.");
case "notFound":
return object === "domain"
? t("This domain no longer exists. Someone may have removed it.")
: object === "group"
? t("This group 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 });
}
}