Add Mailing lists to Administration
A mailing list is an address that passes mail on to everyone on it. To Stalwart it is its own object, x:MailingList, behind sysMailingList*, so it gets its own section under Directory after Groups: search, fifty to a page with each list's recipient count, and a panel to create, edit and delete one. Recipients are a property of the list, so unlike a group's members they save with the rest of the panel. What Save sends for them is only what was added and removed, one recipients/<address> pointer each -- the patch the live server accepted -- so a recipient added elsewhere while the panel was open is not taken out. They can be pasted several at a time, from a spreadsheet column, a comma-separated line or Name <address>; anything with an @ that is not an address stays in the box with a note. Past a dozen, a filter narrows them. That is all a list is in Stalwart -- no owners, moderation or posting rules -- so that is all the panel offers. The mock answers x:MailingList with two lists, the recipient set's live shape, and the refusals a wrong address, a clash with an account and a missing permission get. Twenty-five new strings and one plural, in all nine catalogues.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/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();
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole
|
||||
return perms.has(`sys${object}${op}`);
|
||||
}
|
||||
|
||||
export type AdminSection = "dashboard" | "accounts" | "groups" | "domains";
|
||||
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "domains";
|
||||
|
||||
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
||||
|
||||
@@ -62,6 +62,7 @@ export function adminSections(perms: Permissions): 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, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ const VALIDATOR_MESSAGES: Record<string, () => string> = {
|
||||
};
|
||||
|
||||
/** What kind of thing a refusal was about, where the wording has to differ. */
|
||||
export type DirectoryObject = "account" | "domain" | "group";
|
||||
export type DirectoryObject = "account" | "domain" | "group" | "list";
|
||||
|
||||
/**
|
||||
* Say what went wrong in terms of the person's own action, in their language.
|
||||
@@ -276,7 +276,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
||||
? 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.");
|
||||
: object === "list"
|
||||
? t("Your organisation has reached the number of mailing lists 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":
|
||||
@@ -284,7 +286,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
||||
? 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.");
|
||||
: object === "list"
|
||||
? t("This mailing list 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":
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { DirectoryError, type EmailAlias } from "@/lib/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 };
|
||||
}
|
||||
Reference in New Issue
Block a user