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:
2026-09-15 08:47:04 -07:00
parent 79afc334ab
commit 627422d794
25 changed files with 1113 additions and 13 deletions
+28
View File
@@ -190,3 +190,31 @@ test("a group is created without a password, with Default roles", () => {
assert.equal(list[0]!["@type"], "Group");
assert.ok(!("memberGroupIds" in list[0]!));
});
/** Mailing lists: their own object, with a set of recipient addresses. */
test("a list is created, found by text, and read back with its address", () => {
const dir = make("admin");
const r = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "team", domainId: "d1", recipients: { "[email protected]": true }, aliases: {} } } }) as { created: Record<string, { id: string }> };
const id = r.created.n!.id;
const q = dir.handlers["x:MailingList/query"]!({ filter: { text: "team" }, calculateTotal: true }) as { ids: string[] };
assert.deepEqual(q.ids, [id]);
const { list } = dir.handlers["x:MailingList/get"]!({ ids: [id] }) as { list: Array<{ emailAddress: string; recipients: Record<string, boolean> }> };
assert.match(list[0]!.emailAddress, /^team@/);
assert.deepEqual(list[0]!.recipients, { "[email protected]": true });
});
test("a recipient pointer moves one address, and a bad one is refused", () => {
const dir = make("admin");
dir.handlers["x:MailingList/set"]!({ update: { l2: { "recipients/[email protected]": true, "recipients/[email protected]": null } } });
const read = () => (dir.handlers["x:MailingList/get"]!({ ids: ["l2"] }) as { list: Array<{ recipients: Record<string, boolean> }> }).list[0]!.recipients;
assert.deepEqual(Object.keys(read()).sort(), ["[email protected]", "[email protected]"]);
const bad = dir.handlers["x:MailingList/set"]!({ update: { l2: { "recipients/not-an-address": true } } }) as { notUpdated?: Record<string, { type: string }> };
assert.equal(bad.notUpdated?.l2?.type, "invalidPatch");
});
test("a list's address cannot be one an account already has, and a role without the permission is refused", () => {
const dir = make("admin");
const clash = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "demo", domainId: "d1" } } }) as { notCreated?: Record<string, { type: string }> };
assert.equal(clash.notCreated?.n?.type, "primaryKeyViolation");
assert.throws(() => make("helpdesk").handlers["x:MailingList/query"]!({}), (e: Refused) => e.type === "forbidden");
});