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
@@ -0,0 +1,67 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import type { DirectoryList } from "@/lib/adminLists";
import type { DirectoryContext } from "../directoryContext";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const api = vi.hoisted(() => ({ updateList: vi.fn(async () => {}) }));
vi.mock("@/lib/adminLists", async (original) => ({ ...(await original<typeof import("@/lib/adminLists")>()), updateList: api.updateList }));
const { ListSheet } = await import("../ListSheet");
const list: DirectoryList = { id: "l1", name: "announce", domainId: "d1", emailAddress: "[email protected]", description: "Announcements", recipients: { "[email protected]": true, "[email protected]": true }, aliases: {} };
const ctx: DirectoryContext = { domains: [{ id: "d1", name: "example.com" }], roles: null, groups: new Map(), self: { ids: new Set(), address: "[email protected]" } };
const signIn = (permissions: string[]) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
const button = (host: HTMLElement, label: string) => [...host.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === label || b.textContent?.trim() === label);
const type = async (input: HTMLInputElement, value: string) => {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
};
describe("the mailing list sheet", () => {
let host: HTMLDivElement;
let root: Root;
const render = async () => {
await act(async () => {
root.render(<ListSheet list={list} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
api.updateList.mockClear();
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("saves only the recipients added and removed, and says what it could not read", async () => {
signIn(["sysMailingListGet", "sysMailingListQuery", "sysMailingListUpdate"]);
await render();
await act(async () => button(host, "Remove [email protected]")!.click());
await type(host.querySelector<HTMLInputElement>('input[aria-label="Add recipients"]')!, "Bob <[email protected]>, oops@");
await act(async () => button(host, "Add")!.click());
expect(host.querySelector(".admin-notice.warn")?.textContent).toContain("oops@");
expect(host.querySelector<HTMLInputElement>('input[aria-label="Add recipients"]')!.value).toBe("oops@");
await act(async () => button(host, "Save changes")!.click());
expect(api.updateList).toHaveBeenCalledWith("l1", { "recipients/[email protected]": null, "recipients/[email protected]": true });
});
it("offers nothing to change to a role that can only read, and no delete without the permission", async () => {
signIn(["sysMailingListGet", "sysMailingListQuery"]);
await render();
expect(host.textContent).toContain("Your role lets you view mailing lists but not change them.");
expect(button(host, "Remove [email protected]")).toBeUndefined();
expect(host.querySelector('input[aria-label="Add recipients"]')).toBeNull();
expect(button(host, "Delete list…")).toBeUndefined();
});
});