Merge pull request #362 from Coffey-Labs/feat/admin-lists

Add Mailing lists to Administration
This commit is contained in:
jcoffey
2026-09-15 09:50:42 -07:00
committed by GitHub
25 changed files with 1113 additions and 13 deletions
+24 -3
View File
@@ -1209,6 +1209,27 @@ it in the menu.
Groups do not contain groups; Stalwart has no nesting.
## Mailing lists
A mailing list is an address that passes mail on to everyone on it, on this
server or anywhere else. For a role with `sysMailingListQuery` and
`sysMailingListGet`, under Directory after Groups:
- **List and search** by name or address, with each list's recipient count.
- **Create** a list on a domain; **edit** its display name, recipients and
other addresses, which save together.
- **Recipients** can be pasted several at a time — a column from a
spreadsheet, a line of addresses separated by commas, `Name <address>`
and anything with an @ that is not an address stays in the box with a note
rather than being dropped. Past a dozen, a filter narrows them. Saving sends
only the addresses added and removed, so a recipient someone else added while
the panel was open is not lost.
- **Delete** asks for the address to be typed. The recipients' own mail is not
touched.
That is all a list is in Stalwart: there are no owners, moderators or posting
rules to set.
## Domains
For a role that can read domains (`sysDomainQuery`, `sysDomainGet`):
@@ -1267,8 +1288,8 @@ session information already kept for thirty minutes — so a role granted or
taken away shows in the menu at the next sign-in or within half an hour, and in
the meantime Stalwart refuses what is no longer allowed.
The dashboard, accounts, groups and domains are the sections so far. Mailing
lists, roles and tenants are Stalwart capabilities the same screen is
The dashboard, accounts, groups, mailing lists and domains are the sections so
far. Roles and tenants are Stalwart capabilities the same screen is
laid out to take. Beyond the dashboard's counts, managing queues, logs and
server settings is deliberately out of scope.
@@ -1736,7 +1757,7 @@ demo user is: `admin` (the default), `tenant-admin` (the queue but not the
history), `helpdesk` — a custom role that may view and edit accounts but not
create or delete them, and read domains — or `user`, who is not offered the
menu at all. `MOCK_METRICS=off` refuses the history the way a Community server
does.
does. Two mailing lists round it out.
---
+2
View File
@@ -71,6 +71,8 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- **Membership is the member's.** `"memberGroupIds/<group>": true` on the user was accepted; `{"@type": "User", "memberGroupIds": <group>}` then found them with a total of 1, and the user's own `memberGroupIds` read `{"<group>": true}`. The same pointer with `null` took them out again and left the set as it was before.
- **A group with members cannot be deleted**: `objectIsLinked`, with `objectId` as `{"object": "Account", "id": <group>}` and `linkedObjects` listing each member as `{"object": "Account", "id": …}`. With the member out, the delete went through and the group read back as `notFound`.
The same run tried a throwaway mailing list, `[email protected]`, before the Mailing lists section was written. It was created with `recipients` as a set, `{"[email protected]": true}`, and read back as `name`, `domainId`, `description`, `aliases`, `memberTenantId`, `recipients` and a computed `emailAddress`. `"recipients/<address>": true` added one and left the other; a `text` filter found it; it was destroyed with nothing linked. Not tried: removing a recipient with `null` (the same set patch as a group membership, which was), and how the server words a recipient that is not an address.
Still from source only: that membership gives a member no permissions (`access_token.rs` builds a user's permissions from their own roles), and that groups cannot nest.
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognised by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
+1 -1
View File
@@ -83,7 +83,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
- **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes)
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: a dashboard of users, domains, queued mail, memory and the last day's received and sent, scoped to a tenant administrator's own tenancy; create, edit and delete accounts and set their passwords; create groups and add or remove their members; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: a dashboard of users, domains, queued mail, memory and the last day's received and sent, scoped to a tenant administrator's own tenancy; create, edit and delete accounts and set their passwords; create groups and add or remove their members; create mailing lists and paste in their recipients; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
+1 -1
View File
@@ -8,7 +8,7 @@ the rest is here because the answer is "no", not "not yet".
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
- **Administration beyond accounts, groups and domains.** The Administration menu opens on a dashboard and manages accounts, groups and domains today — see [FEATURES.md](FEATURES.md#administration). Mailing lists, roles, DNS and ACME providers and tenants are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
- **Administration beyond accounts, groups, mailing lists and domains.** The Administration menu opens on a dashboard and manages accounts, groups, mailing lists and domains today — see [FEATURES.md](FEATURES.md#administration). Roles, DNS and ACME providers and tenants are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
+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");
});
+58 -3
View File
@@ -215,18 +215,28 @@ export function createDirectory(opts: Options) {
if (opts.metricsOff) throw opts.fail("forbidden", "This feature is only available in the Enterprise edition of Stalwart.");
};
/**
* Mailing lists: an address and the addresses it passes mail on to. The
* recipient set's shape is the live server's (2026-09-15).
*/
const lists: Obj[] = [
{ id: "l1", name: "announce", domainId: "d1", description: "Announcements", recipients: flags([opts.user, "[email protected]", "[email protected]", "[email protected]"]), aliases: {}, memberTenantId: null },
{ id: "l2", name: "board", domainId: "d2", description: "Board", recipients: flags(["[email protected]", "[email protected]"]), aliases: {}, memberTenantId: null },
];
const addressOk = (a: unknown) => typeof a === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a);
const demand = (perm: string) => {
if (!permissions.has(perm)) throw opts.fail("forbidden", `You do not have the ${perm} permission.`);
};
const domainName = (id: unknown) => domains.find((d) => d.id === id)?.name as string | undefined;
const addressOf = (o: Obj) => `${o.name}@${domainName(o.domainId) ?? "invalid"}`;
/** Every address in use, primary and alias, across accounts. */
/** Every address in use, primary and alias, across accounts and mailing lists. */
const addressTaken = (address: string, except?: string) =>
accounts.some((a) => a.id !== except && (addressOf(a) === address || Object.values((a.aliases as Obj) ?? {}).some((al) => `${(al as Obj).name}@${domainName((al as Obj).domainId)}` === address)));
[...accounts, ...lists].some((a) => a.id !== except && (addressOf(a) === address || Object.values((a.aliases as Obj) ?? {}).some((al) => `${(al as Obj).name}@${domainName((al as Obj).domainId)}` === address)));
const view = (o: Obj, properties: unknown): Obj => {
const full: Obj = { ...o };
if (accounts.includes(o)) full.emailAddress = addressOf(o);
if (accounts.includes(o) || lists.includes(o)) full.emailAddress = addressOf(o);
if (domains.includes(o)) full.dnsZoneFile = zoneFile(o);
if (full.credentials) {
full.credentials = Object.fromEntries(Object.entries(full.credentials as Obj).map(([k, c]) => [k, { ...(c as Obj), secret: MASKED }]));
@@ -465,6 +475,51 @@ export function createDirectory(opts: Options) {
(f.timestampIsLessThanOrEqual === undefined || String(o.timestamp) <= String(f.timestampIsLessThanOrEqual)) &&
(!Array.isArray(f.metric) || (f.metric as string[]).includes(o.metric as string)))(a);
},
"x:MailingList/get": get(lists, "sysMailingListGet"),
"x:MailingList/query": query(() => lists, "sysMailingListQuery", ["text", "memberTenantId"], (o, f) => matchText(o, f.text)),
"x:MailingList/set": (a) => {
const created: Obj = {};
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
const destroyed: string[] = [];
const notDestroyed: Obj = {};
const check = (o: Obj, id?: string): Obj | null => {
if (typeof o.name !== "string" || !/^[a-z0-9._-]+$/i.test(o.name)) return setError("invalidProperties", "Invalid email local part", ["name"]);
if (!domainName(o.domainId)) return setError("invalidForeignKey", "Domain does not exist.", ["domainId"]);
if (addressTaken(`${o.name}@${domainName(o.domainId)}`, id)) return setError("primaryKeyViolation", "An account or alias with this email address already exists.");
if (Object.keys((o.recipients as Obj) ?? {}).some((r) => !addressOk(r))) return setError("invalidProperties", "Invalid email address", ["recipients"]);
return null;
};
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
demand("sysMailingListCreate");
const o: Obj = { recipients: {}, aliases: {}, description: null, ...(raw as Obj) };
const failure = check(o);
if (failure) { notCreated[cid] = failure; continue; }
const id = `l${counter++}`;
lists.push({ ...o, id, memberTenantId: null });
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
}
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
demand("sysMailingListUpdate");
const target = lists.find((x) => x.id === id);
if (!target) { notUpdated[id] = setError("notFound", "Mailing list not found."); continue; }
const next = structuredClone(target);
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
const failure = check(next, id);
if (failure) { notUpdated[id] = { ...failure, type: failure.type === "invalidProperties" ? "invalidPatch" : failure.type }; continue; }
Object.assign(target, next);
updated[id] = null;
}
for (const id of (a.destroy as string[]) ?? []) {
demand("sysMailingListDestroy");
const i = lists.findIndex((x) => x.id === id);
if (i < 0) { notDestroyed[id] = setError("notFound", "Mailing list not found."); continue; }
lists.splice(i, 1);
destroyed.push(id);
}
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
},
"x:Role/get": get(roles, "sysRoleGet"),
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => matchText(o, f.description)),
};
+48
View File
@@ -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();
});
});
+2 -1
View File
@@ -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;
}
+5 -1
View File
@@ -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,6 +276,8 @@ 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.")
: 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.");
@@ -284,6 +286,8 @@ 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.")
: 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.");
+141
View File
@@ -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 };
}
+26
View File
@@ -182,6 +182,31 @@ export const catalog: Catalog = {
"Your organisation has reached the number of groups it is allowed.": "Ihre Organisation hat die erlaubte Anzahl an Gruppen erreicht.",
"This group no longer exists. Someone may have deleted it.": "Diese Gruppe existiert nicht mehr. Jemand hat sie möglicherweise gelöscht.",
"The server did not say whether the group was created.": "Der Server hat nicht mitgeteilt, ob die Gruppe angelegt wurde.",
"Mailing lists": "Mailinglisten",
"Mailing list": "Mailingliste",
"A list needs an address.": "Eine Liste braucht eine Adresse.",
"New mailing list": "Neue Mailingliste",
"Your role lets you view mailing lists but not change them.": "Ihre Rolle erlaubt es, Mailinglisten anzusehen, aber nicht zu ändern.",
"No domains are available to create a list on.": "Es gibt keine Domains, auf denen eine Liste angelegt werden kann.",
"Mail to these addresses goes to the list too. Changes apply when you save.": "E-Mails an diese Adressen gehen ebenfalls an die Liste. Änderungen gelten beim Speichern.",
"Create list": "Liste anlegen",
"Filter recipients": "Empfänger filtern",
"No recipients match": "Keine passenden Empfänger",
"Add recipients": "Empfänger hinzufügen",
"Addresses, separated by commas": "Adressen, durch Kommas getrennt",
"Not added, as they aren't addresses: {items}": "Nicht hinzugefügt, da es keine Adressen sind: {items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "E-Mails an die Liste werden an alle Empfänger weitergeleitet, auf diesem Server oder anderswo. Sie können mehrere auf einmal einfügen. Änderungen gelten beim Speichern.",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "E-Mails an diese Adresse werden nicht mehr weitergeleitet. Die eigenen E-Mails der Empfänger bleiben unberührt.",
"Delete list…": "Liste löschen…",
"Delete list": "Liste löschen",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "E-Mails an diese Adresse werden an niemanden mehr weitergeleitet. Das lässt sich nicht rückgängig machen.",
"Addresses that pass mail on to everyone on them.": "Adressen, die E-Mails an alle weiterleiten, die darauf stehen.",
"Search mailing lists": "Mailinglisten durchsuchen",
"No mailing lists match": "Keine passenden Mailinglisten",
"No mailing lists yet": "Noch keine Mailinglisten",
"Your organisation has reached the number of mailing lists it is allowed.": "Ihre Organisation hat die erlaubte Anzahl an Mailinglisten erreicht.",
"This mailing list no longer exists. Someone may have deleted it.": "Diese Mailingliste existiert nicht mehr. Jemand hat sie möglicherweise gelöscht.",
"The server did not say whether the list was created.": "Der Server hat nicht mitgeteilt, ob die Liste angelegt wurde.",
"User": "Benutzer",
"Administrator": "Administrator",
"Custom role": "Eigene Rolle",
@@ -1596,6 +1621,7 @@ export const catalog: Catalog = {
"{n} groups": { one: "{n} Gruppe", other: "{n} Gruppen" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Sein {n} Mitglied wird zuerst aus der Gruppe entfernt und verliert, was mit ihr geteilt wurde. Die E-Mails der Gruppe werden im Hintergrund entfernt, und das lässt sich nicht rückgängig machen.", other: "Ihre {n} Mitglieder werden zuerst aus der Gruppe entfernt und verlieren, was mit ihr geteilt wurde. Die E-Mails der Gruppe werden im Hintergrund entfernt, und das lässt sich nicht rückgängig machen." },
"{n} mailing lists": { one: "{n} Mailingliste", other: "{n} Mailinglisten" },
"{n} recipients": { one: "{n} Empfänger", other: "{n} Empfänger" },
"{n} DKIM keys": { one: "{n} DKIM-Schlüssel", other: "{n} DKIM-Schlüssel" },
"{n} other items": { one: "{n} weiteres Objekt", other: "{n} weitere Objekte" },
// ── Administration ────────────────────────────────────────────────
+26
View File
@@ -174,6 +174,31 @@ export const catalog: Catalog = {
"Your organisation has reached the number of groups it is allowed.": "Su organización ha alcanzado el número de grupos permitido.",
"This group no longer exists. Someone may have deleted it.": "Este grupo ya no existe. Puede que alguien lo haya eliminado.",
"The server did not say whether the group was created.": "El servidor no indicó si el grupo se creó.",
"Mailing lists": "Listas de correo",
"Mailing list": "Lista de correo",
"A list needs an address.": "Una lista necesita una dirección.",
"New mailing list": "Nueva lista de correo",
"Your role lets you view mailing lists but not change them.": "Su rol le permite ver las listas de correo, pero no modificarlas.",
"No domains are available to create a list on.": "No hay dominios disponibles para crear una lista.",
"Mail to these addresses goes to the list too. Changes apply when you save.": "El correo a estas direcciones también va a la lista. Los cambios se aplican al guardar.",
"Create list": "Crear lista",
"Filter recipients": "Filtrar destinatarios",
"No recipients match": "Ningún destinatario coincide",
"Add recipients": "Añadir destinatarios",
"Addresses, separated by commas": "Direcciones separadas por comas",
"Not added, as they aren't addresses: {items}": "No se añadieron porque no son direcciones: {items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "El correo a la lista se reenvía a cada destinatario, en este servidor o en cualquier otro. Puede pegar varios a la vez. Los cambios se aplican al guardar.",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "El correo a esta dirección deja de reenviarse. El correo propio de los destinatarios no se toca.",
"Delete list…": "Eliminar lista…",
"Delete list": "Eliminar lista",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "El correo a esta dirección ya no se reenvía a nadie. No se puede deshacer.",
"Addresses that pass mail on to everyone on them.": "Direcciones que reenvían el correo a todos los que están en ellas.",
"Search mailing lists": "Buscar listas de correo",
"No mailing lists match": "Ninguna lista de correo coincide",
"No mailing lists yet": "Aún no hay listas de correo",
"Your organisation has reached the number of mailing lists it is allowed.": "Su organización ha alcanzado el número de listas de correo permitido.",
"This mailing list no longer exists. Someone may have deleted it.": "Esta lista de correo ya no existe. Puede que alguien la haya eliminado.",
"The server did not say whether the list was created.": "El servidor no indicó si la lista se creó.",
"User": "Usuario",
"Administrator": "Administrador",
"Custom role": "Rol personalizado",
@@ -1569,6 +1594,7 @@ export const catalog: Catalog = {
"{n} groups": { one: "{n} grupo", other: "{n} grupos" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Primero se quita del grupo a su {n} miembro, que pierde lo que se compartía con él. El correo del grupo se elimina en segundo plano, y no se puede deshacer.", other: "Primero se quita del grupo a sus {n} miembros, que pierden lo que se compartía con él. El correo del grupo se elimina en segundo plano, y no se puede deshacer." },
"{n} mailing lists": { one: "{n} lista de correo", other: "{n} listas de correo" },
"{n} recipients": { one: "{n} destinatario", other: "{n} destinatarios" },
"{n} DKIM keys": { one: "{n} clave DKIM", other: "{n} claves DKIM" },
"{n} other items": { one: "{n} elemento más", other: "{n} elementos más" },
// ── Administration ────────────────────────────────────────────────
+26
View File
@@ -179,6 +179,31 @@ export const catalog: Catalog = {
"Your organisation has reached the number of groups it is allowed.": "Votre organisation a atteint le nombre de groupes autorisé.",
"This group no longer exists. Someone may have deleted it.": "Ce groupe nexiste plus. Quelquun la peut-être supprimé.",
"The server did not say whether the group was created.": "Le serveur na pas indiqué si le groupe a été créé.",
"Mailing lists": "Listes de diffusion",
"Mailing list": "Liste de diffusion",
"A list needs an address.": "Une liste a besoin dune adresse.",
"New mailing list": "Nouvelle liste de diffusion",
"Your role lets you view mailing lists but not change them.": "Votre rôle vous permet de consulter les listes de diffusion, mais pas de les modifier.",
"No domains are available to create a list on.": "Aucun domaine nest disponible pour créer une liste.",
"Mail to these addresses goes to the list too. Changes apply when you save.": "Les messages envoyés à ces adresses vont aussi à la liste. Les modifications sappliquent à lenregistrement.",
"Create list": "Créer la liste",
"Filter recipients": "Filtrer les destinataires",
"No recipients match": "Aucun destinataire ne correspond",
"Add recipients": "Ajouter des destinataires",
"Addresses, separated by commas": "Adresses séparées par des virgules",
"Not added, as they aren't addresses: {items}": "Non ajoutés, car ce ne sont pas des adresses : {items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "Les messages envoyés à la liste sont transmis à chaque destinataire, sur ce serveur ou ailleurs. Vous pouvez en coller plusieurs à la fois. Les modifications sappliquent à lenregistrement.",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "Les messages envoyés à cette adresse ne sont plus transmis. Les messages des destinataires eux-mêmes ne sont pas touchés.",
"Delete list…": "Supprimer la liste…",
"Delete list": "Supprimer la liste",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "Les messages envoyés à cette adresse ne sont plus transmis à personne. Cest irréversible.",
"Addresses that pass mail on to everyone on them.": "Des adresses qui transmettent les messages à tous leurs destinataires.",
"Search mailing lists": "Rechercher des listes de diffusion",
"No mailing lists match": "Aucune liste de diffusion ne correspond",
"No mailing lists yet": "Aucune liste de diffusion pour linstant",
"Your organisation has reached the number of mailing lists it is allowed.": "Votre organisation a atteint le nombre de listes de diffusion autorisé.",
"This mailing list no longer exists. Someone may have deleted it.": "Cette liste de diffusion nexiste plus. Quelquun la peut-être supprimée.",
"The server did not say whether the list was created.": "Le serveur na pas indiqué si la liste a été créée.",
"User": "Utilisateur",
"Administrator": "Administrateur",
"Custom role": "Rôle personnalisé",
@@ -1574,6 +1599,7 @@ export const catalog: Catalog = {
"{n} groups": { one: "{n} groupe", other: "{n} groupes" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Son {n} membre est dabord retiré du groupe et perd ce qui était partagé avec lui. Les messages du groupe sont supprimés en arrière-plan, et cest irréversible.", other: "Ses {n} membres sont dabord retirés du groupe et perdent ce qui était partagé avec lui. Les messages du groupe sont supprimés en arrière-plan, et cest irréversible." },
"{n} mailing lists": { one: "{n} liste de diffusion", other: "{n} listes de diffusion" },
"{n} recipients": { one: "{n} destinataire", other: "{n} destinataires" },
"{n} DKIM keys": { one: "{n} clé DKIM", other: "{n} clés DKIM" },
"{n} other items": { one: "{n} autre élément", other: "{n} autres éléments" },
// ── Administration ────────────────────────────────────────────────
+26
View File
@@ -173,6 +173,31 @@ export const catalog: Catalog = {
"Your organisation has reached the number of groups it is allowed.": "組織で許可されているグループ数の上限に達しました。",
"This group no longer exists. Someone may have deleted it.": "このグループはもう存在しません。誰かが削除した可能性があります。",
"The server did not say whether the group was created.": "グループが作成されたかどうか、サーバーから返答がありませんでした。",
"Mailing lists": "メーリングリスト",
"Mailing list": "メーリングリスト",
"A list needs an address.": "リストにはアドレスが必要です。",
"New mailing list": "新しいメーリングリスト",
"Your role lets you view mailing lists but not change them.": "あなたのロールでは、メーリングリストの閲覧はできますが変更はできません。",
"No domains are available to create a list on.": "リストを作成できるドメインがありません。",
"Mail to these addresses goes to the list too. Changes apply when you save.": "これらのアドレス宛てのメールもリストに届きます。変更は保存時に反映されます。",
"Create list": "リストを作成",
"Filter recipients": "受信者を絞り込む",
"No recipients match": "一致する受信者はいません",
"Add recipients": "受信者を追加",
"Addresses, separated by commas": "アドレス(カンマ区切り)",
"Not added, as they aren't addresses: {items}": "アドレスではないため追加されませんでした: {items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "リスト宛てのメールは、このサーバー上でも他のサーバー上でも、すべての受信者に転送されます。複数をまとめて貼り付けることもできます。変更は保存時に反映されます。",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "このアドレス宛てのメールは転送されなくなります。受信者自身のメールには影響しません。",
"Delete list…": "リストを削除…",
"Delete list": "リストを削除",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "このアドレス宛てのメールは誰にも転送されなくなります。元に戻せません。",
"Addresses that pass mail on to everyone on them.": "登録されている全員にメールを転送するアドレスです。",
"Search mailing lists": "メーリングリストを検索",
"No mailing lists match": "一致するメーリングリストはありません",
"No mailing lists yet": "まだメーリングリストがありません",
"Your organisation has reached the number of mailing lists it is allowed.": "組織で許可されているメーリングリスト数の上限に達しました。",
"This mailing list no longer exists. Someone may have deleted it.": "このメーリングリストはもう存在しません。誰かが削除した可能性があります。",
"The server did not say whether the list was created.": "リストが作成されたかどうか、サーバーから返答がありませんでした。",
"User": "ユーザー",
"Administrator": "管理者",
"Custom role": "カスタムロール",
@@ -1577,6 +1602,7 @@ export const catalog: Catalog = {
"{n} groups": { other: "{n} 件のグループ" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { other: "まず {n} 人のメンバーがグループから外され、グループと共有されていたものを使えなくなります。グループのメールはバックグラウンドで削除され、元に戻せません。" },
"{n} mailing lists": { other: "{n} 件のメーリングリスト" },
"{n} recipients": { other: "{n} 件の受信者" },
"{n} DKIM keys": { other: "{n} 個の DKIM 鍵" },
"{n} other items": { other: "その他 {n} 件" },
// ── Administration ────────────────────────────────────────────────
+26
View File
@@ -170,6 +170,31 @@ export const catalog: Catalog = {
"Your organisation has reached the number of groups it is allowed.": "Uw organisatie heeft het toegestane aantal groepen bereikt.",
"This group no longer exists. Someone may have deleted it.": "Deze groep bestaat niet meer. Iemand heeft hem mogelijk verwijderd.",
"The server did not say whether the group was created.": "De server heeft niet gemeld of de groep is aangemaakt.",
"Mailing lists": "Mailinglijsten",
"Mailing list": "Mailinglijst",
"A list needs an address.": "Een lijst heeft een adres nodig.",
"New mailing list": "Nieuwe mailinglijst",
"Your role lets you view mailing lists but not change them.": "Met uw rol kunt u mailinglijsten bekijken, maar niet wijzigen.",
"No domains are available to create a list on.": "Er zijn geen domeinen waarop een lijst kan worden aangemaakt.",
"Mail to these addresses goes to the list too. Changes apply when you save.": "E-mail aan deze adressen gaat ook naar de lijst. Wijzigingen gelden zodra u opslaat.",
"Create list": "Lijst aanmaken",
"Filter recipients": "Ontvangers filteren",
"No recipients match": "Geen ontvangers gevonden",
"Add recipients": "Ontvangers toevoegen",
"Addresses, separated by commas": "Adressen, gescheiden door komma's",
"Not added, as they aren't addresses: {items}": "Niet toegevoegd, want dit zijn geen adressen: {items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "E-mail aan de lijst wordt doorgestuurd naar elke ontvanger, op deze server of elders. U kunt er meerdere tegelijk plakken. Wijzigingen gelden zodra u opslaat.",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "E-mail aan dit adres wordt niet meer doorgestuurd. De eigen e-mail van de ontvangers blijft ongemoeid.",
"Delete list…": "Lijst verwijderen…",
"Delete list": "Lijst verwijderen",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "E-mail aan dit adres wordt aan niemand meer doorgestuurd. Dit kan niet ongedaan worden gemaakt.",
"Addresses that pass mail on to everyone on them.": "Adressen die e-mail doorsturen naar iedereen die erop staat.",
"Search mailing lists": "Mailinglijsten zoeken",
"No mailing lists match": "Geen mailinglijsten gevonden",
"No mailing lists yet": "Nog geen mailinglijsten",
"Your organisation has reached the number of mailing lists it is allowed.": "Uw organisatie heeft het toegestane aantal mailinglijsten bereikt.",
"This mailing list no longer exists. Someone may have deleted it.": "Deze mailinglijst bestaat niet meer. Iemand heeft hem mogelijk verwijderd.",
"The server did not say whether the list was created.": "De server heeft niet gemeld of de lijst is aangemaakt.",
"User": "Gebruiker",
"Administrator": "Beheerder",
"Custom role": "Aangepaste rol",
@@ -1565,6 +1590,7 @@ export const catalog: Catalog = {
"{n} groups": { one: "{n} groep", other: "{n} groepen" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Het {n} lid wordt eerst uit de groep gehaald en verliest wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt.", other: "De {n} leden worden eerst uit de groep gehaald en verliezen wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt." },
"{n} mailing lists": { one: "{n} mailinglijst", other: "{n} mailinglijsten" },
"{n} recipients": { one: "{n} ontvanger", other: "{n} ontvangers" },
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
// ── Administration ────────────────────────────────────────────────
+26
View File
@@ -177,6 +177,31 @@ export const catalog: Catalog = {
"Your organisation has reached the number of groups it is allowed.": "Sua organização atingiu o número de grupos permitido.",
"This group no longer exists. Someone may have deleted it.": "Este grupo não existe mais. Alguém pode tê-lo excluído.",
"The server did not say whether the group was created.": "O servidor não informou se o grupo foi criado.",
"Mailing lists": "Listas de e-mail",
"Mailing list": "Lista de e-mail",
"A list needs an address.": "Uma lista precisa de um endereço.",
"New mailing list": "Nova lista de e-mail",
"Your role lets you view mailing lists but not change them.": "Sua função permite ver as listas de e-mail, mas não alterá-las.",
"No domains are available to create a list on.": "Não há domínios disponíveis para criar uma lista.",
"Mail to these addresses goes to the list too. Changes apply when you save.": "Os e-mails para estes endereços também vão para a lista. As alterações valem ao salvar.",
"Create list": "Criar lista",
"Filter recipients": "Filtrar destinatários",
"No recipients match": "Nenhum destinatário corresponde",
"Add recipients": "Adicionar destinatários",
"Addresses, separated by commas": "Endereços separados por vírgulas",
"Not added, as they aren't addresses: {items}": "Não adicionados, pois não são endereços: {items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "Os e-mails para a lista são repassados a cada destinatário, neste servidor ou em qualquer outro. Você pode colar vários de uma vez. As alterações valem ao salvar.",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "Os e-mails para este endereço deixam de ser repassados. Os e-mails dos próprios destinatários não são afetados.",
"Delete list…": "Excluir lista…",
"Delete list": "Excluir lista",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "Os e-mails para este endereço não são mais repassados a ninguém. Isso não pode ser desfeito.",
"Addresses that pass mail on to everyone on them.": "Endereços que repassam os e-mails a todos que estão neles.",
"Search mailing lists": "Pesquisar listas de e-mail",
"No mailing lists match": "Nenhuma lista de e-mail corresponde",
"No mailing lists yet": "Nenhuma lista de e-mail ainda",
"Your organisation has reached the number of mailing lists it is allowed.": "Sua organização atingiu o número de listas de e-mail permitido.",
"This mailing list no longer exists. Someone may have deleted it.": "Esta lista de e-mail não existe mais. Alguém pode tê-la excluído.",
"The server did not say whether the list was created.": "O servidor não informou se a lista foi criada.",
"User": "Usuário",
"Administrator": "Administrador",
"Custom role": "Função personalizada",
@@ -1572,6 +1597,7 @@ export const catalog: Catalog = {
"{n} groups": { one: "{n} grupo", other: "{n} grupos" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "O {n} membro é retirado do grupo primeiro e perde o que era compartilhado com ele. Os e-mails do grupo são removidos em segundo plano, e isso não pode ser desfeito.", other: "Os {n} membros são retirados do grupo primeiro e perdem o que era compartilhado com ele. Os e-mails do grupo são removidos em segundo plano, e isso não pode ser desfeito." },
"{n} mailing lists": { one: "{n} lista de e-mails", other: "{n} listas de e-mails" },
"{n} recipients": { one: "{n} destinatário", other: "{n} destinatários" },
"{n} DKIM keys": { one: "{n} chave DKIM", other: "{n} chaves DKIM" },
"{n} other items": { one: "{n} outro item", other: "{n} outros itens" },
// ── Administration ────────────────────────────────────────────────
+26
View File
@@ -176,6 +176,31 @@ export const catalog: Catalog = {
"Your organisation has reached the number of groups it is allowed.": "Ваша организация достигла допустимого числа групп.",
"This group no longer exists. Someone may have deleted it.": "Этой группы больше нет. Возможно, её кто-то удалил.",
"The server did not say whether the group was created.": "Сервер не сообщил, создана ли группа.",
"Mailing lists": "Списки рассылки",
"Mailing list": "Список рассылки",
"A list needs an address.": "Списку нужен адрес.",
"New mailing list": "Новый список рассылки",
"Your role lets you view mailing lists but not change them.": "Ваша роль позволяет просматривать списки рассылки, но не изменять их.",
"No domains are available to create a list on.": "Нет доменов, на которых можно создать список.",
"Mail to these addresses goes to the list too. Changes apply when you save.": "Почта на эти адреса тоже идёт в список. Изменения применяются при сохранении.",
"Create list": "Создать список",
"Filter recipients": "Фильтр получателей",
"No recipients match": "Нет подходящих получателей",
"Add recipients": "Добавить получателей",
"Addresses, separated by commas": "Адреса через запятую",
"Not added, as they aren't addresses: {items}": "Не добавлены, так как это не адреса: {items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "Почта в список пересылается каждому получателю — на этом сервере или любом другом. Можно вставить сразу несколько. Изменения применяются при сохранении.",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "Почта на этот адрес больше не пересылается. Собственная почта получателей не затрагивается.",
"Delete list…": "Удалить список…",
"Delete list": "Удалить список",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "Почта на этот адрес больше никому не пересылается. Это нельзя отменить.",
"Addresses that pass mail on to everyone on them.": "Адреса, которые пересылают почту всем, кто в них состоит.",
"Search mailing lists": "Поиск списков рассылки",
"No mailing lists match": "Нет подходящих списков рассылки",
"No mailing lists yet": "Списков рассылки пока нет",
"Your organisation has reached the number of mailing lists it is allowed.": "Ваша организация достигла допустимого числа списков рассылки.",
"This mailing list no longer exists. Someone may have deleted it.": "Этого списка рассылки больше нет. Возможно, его кто-то удалил.",
"The server did not say whether the list was created.": "Сервер не сообщил, создан ли список.",
"User": "Пользователь",
"Administrator": "Администратор",
"Custom role": "Особая роль",
@@ -1571,6 +1596,7 @@ export const catalog: Catalog = {
"{n} groups": { one: "{n} группа", few: "{n} группы", many: "{n} групп", other: "{n} группы" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Сначала {n} участник убирается из группы и теряет то, чем с ней поделились. Почта группы удаляется в фоновом режиме, и это нельзя отменить.", few: "Сначала {n} участника убираются из группы и теряют то, чем с ней поделились. Почта группы удаляется в фоновом режиме, и это нельзя отменить.", many: "Сначала {n} участников убираются из группы и теряют то, чем с ней поделились. Почта группы удаляется в фоновом режиме, и это нельзя отменить.", other: "Сначала {n} участника убираются из группы и теряют то, чем с ней поделились. Почта группы удаляется в фоновом режиме, и это нельзя отменить." },
"{n} mailing lists": { one: "{n} список рассылки", few: "{n} списка рассылки", many: "{n} списков рассылки", other: "{n} списка рассылки" },
"{n} recipients": { one: "{n} получатель", few: "{n} получателя", many: "{n} получателей", other: "{n} получателя" },
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключа DKIM", many: "{n} ключей DKIM", other: "{n} ключа DKIM" },
"{n} other items": { one: "{n} другой объект", few: "{n} других объекта", many: "{n} других объектов", other: "{n} другого объекта" },
// ── Administration ────────────────────────────────────────────────
+26
View File
@@ -170,6 +170,31 @@ export const catalog: Catalog = {
"Your organisation has reached the number of groups it is allowed.": "Ваша організація досягла дозволеної кількості груп.",
"This group no longer exists. Someone may have deleted it.": "Цієї групи більше немає. Можливо, її хтось видалив.",
"The server did not say whether the group was created.": "Сервер не повідомив, чи створено групу.",
"Mailing lists": "Списки розсилки",
"Mailing list": "Список розсилки",
"A list needs an address.": "Списку потрібна адреса.",
"New mailing list": "Новий список розсилки",
"Your role lets you view mailing lists but not change them.": "Ваша роль дозволяє переглядати списки розсилки, але не змінювати їх.",
"No domains are available to create a list on.": "Немає доменів, на яких можна створити список.",
"Mail to these addresses goes to the list too. Changes apply when you save.": "Пошта на ці адреси теж іде до списку. Зміни застосовуються після збереження.",
"Create list": "Створити список",
"Filter recipients": "Фільтр одержувачів",
"No recipients match": "Немає відповідних одержувачів",
"Add recipients": "Додати одержувачів",
"Addresses, separated by commas": "Адреси через кому",
"Not added, as they aren't addresses: {items}": "Не додано, бо це не адреси: {items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "Пошта до списку пересилається кожному одержувачу — на цьому сервері чи будь-якому іншому. Можна вставити одразу кілька. Зміни застосовуються після збереження.",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "Пошта на цю адресу більше не пересилається. Власна пошта одержувачів не зачіпається.",
"Delete list…": "Видалити список…",
"Delete list": "Видалити список",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "Пошта на цю адресу більше нікому не пересилається. Це не можна скасувати.",
"Addresses that pass mail on to everyone on them.": "Адреси, які пересилають пошту всім, хто в них є.",
"Search mailing lists": "Пошук списків розсилки",
"No mailing lists match": "Немає відповідних списків розсилки",
"No mailing lists yet": "Списків розсилки поки немає",
"Your organisation has reached the number of mailing lists it is allowed.": "Ваша організація досягла дозволеної кількості списків розсилки.",
"This mailing list no longer exists. Someone may have deleted it.": "Цього списку розсилки більше немає. Можливо, його хтось видалив.",
"The server did not say whether the list was created.": "Сервер не повідомив, чи створено список.",
"User": "Користувач",
"Administrator": "Адміністратор",
"Custom role": "Власна роль",
@@ -1565,6 +1590,7 @@ export const catalog: Catalog = {
"{n} groups": { one: "{n} група", few: "{n} групи", many: "{n} груп", other: "{n} групи" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Спочатку {n} учасник прибирається з групи й утрачає те, чим із нею поділилися. Пошта групи видаляється у фоновому режимі, і це не можна скасувати.", few: "Спочатку {n} учасники прибираються з групи й утрачають те, чим із нею поділилися. Пошта групи видаляється у фоновому режимі, і це не можна скасувати.", many: "Спочатку {n} учасників прибирають із групи, і вони втрачають те, чим із нею поділилися. Пошта групи видаляється у фоновому режимі, і це не можна скасувати.", other: "Спочатку {n} учасника прибирають із групи, і вони втрачають те, чим із нею поділилися. Пошта групи видаляється у фоновому режимі, і це не можна скасувати." },
"{n} mailing lists": { one: "{n} список розсилки", few: "{n} списки розсилки", many: "{n} списків розсилки", other: "{n} списку розсилки" },
"{n} recipients": { one: "{n} одержувач", few: "{n} одержувачі", many: "{n} одержувачів", other: "{n} одержувача" },
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключі DKIM", many: "{n} ключів DKIM", other: "{n} ключа DKIM" },
"{n} other items": { one: "{n} інший об'єкт", few: "{n} інші об'єкти", many: "{n} інших об'єктів", other: "{n} іншого об'єкта" },
// ── Administration ────────────────────────────────────────────────
+26
View File
@@ -172,6 +172,31 @@ export const catalog: Catalog = {
"Your organisation has reached the number of groups it is allowed.": "您的组织已达到允许的群组数量。",
"This group no longer exists. Someone may have deleted it.": "此群组已不存在。可能已被他人删除。",
"The server did not say whether the group was created.": "服务器未说明群组是否已创建。",
"Mailing lists": "邮件列表",
"Mailing list": "邮件列表",
"A list needs an address.": "列表需要一个地址。",
"New mailing list": "新建邮件列表",
"Your role lets you view mailing lists but not change them.": "您的角色可以查看邮件列表,但不能更改。",
"No domains are available to create a list on.": "没有可用于创建列表的域名。",
"Mail to these addresses goes to the list too. Changes apply when you save.": "发往这些地址的邮件也会发到列表。更改在保存后生效。",
"Create list": "创建列表",
"Filter recipients": "筛选收件人",
"No recipients match": "没有匹配的收件人",
"Add recipients": "添加收件人",
"Addresses, separated by commas": "地址,用逗号分隔",
"Not added, as they aren't addresses: {items}": "未添加,因为它们不是地址:{items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "发往列表的邮件会转发给每位收件人,无论其在本服务器还是其他服务器。可以一次粘贴多个。更改在保存后生效。",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "发往此地址的邮件将不再转发。收件人自己的邮件不受影响。",
"Delete list…": "删除列表…",
"Delete list": "删除列表",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "发往此地址的邮件将不再转发给任何人。此操作无法撤销。",
"Addresses that pass mail on to everyone on them.": "将邮件转发给列表中每个人的地址。",
"Search mailing lists": "搜索邮件列表",
"No mailing lists match": "没有匹配的邮件列表",
"No mailing lists yet": "还没有邮件列表",
"Your organisation has reached the number of mailing lists it is allowed.": "您的组织已达到允许的邮件列表数量。",
"This mailing list no longer exists. Someone may have deleted it.": "此邮件列表已不存在。可能已被他人删除。",
"The server did not say whether the list was created.": "服务器未说明列表是否已创建。",
"User": "用户",
"Administrator": "管理员",
"Custom role": "自定义角色",
@@ -1576,6 +1601,7 @@ export const catalog: Catalog = {
"{n} groups": { other: "{n} 个群组" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { other: "会先将 {n} 位成员移出群组,他们将失去与群组共享的内容。群组的邮件会在后台删除,且无法撤销。" },
"{n} mailing lists": { other: "{n} 个邮件列表" },
"{n} recipients": { other: "{n} 位收件人" },
"{n} DKIM keys": { other: "{n} 个 DKIM 密钥" },
"{n} other items": { other: "其他 {n} 项" },
// ── Administration ────────────────────────────────────────────────
+2
View File
@@ -1771,6 +1771,8 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.admin-add-member { margin-top: 10px; }
.admin-add-member .admin-search { max-width: none; display: block; }
.admin-suggestions { margin-top: 6px; }
.admin-recipients { max-height: 220px; overflow-y: auto; padding: 2px 0; }
.admin-recipient-filter { display: block; max-width: none; margin-bottom: 8px; }
.admin-kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; align-items: center; margin: 0; font-size: .92em; }
.admin-kv dt { color: var(--fg-muted); }
.admin-kv dd { margin: 0; }
+2 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Globe, LayoutDashboard, User, UsersRound } from "lucide-react";
import { Globe, LayoutDashboard, List, User, UsersRound } from "lucide-react";
import { adminSections, type AdminSection } from "@/lib/adminAccess";
import { t } from "@/lib/i18n";
import { usePermissions } from "./usePermissions";
@@ -9,6 +9,7 @@ export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string
dashboard: { group: "Overview", label: "Dashboard", icon: <LayoutDashboard size={20} /> },
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
groups: { group: "Directory", label: "Groups", icon: <UsersRound size={20} /> },
lists: { group: "Directory", label: "Mailing lists", icon: <List size={20} /> },
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
};
+2
View File
@@ -5,6 +5,7 @@ import { AccountsAdmin } from "./AccountsAdmin";
import { AdminDashboard } from "./AdminDashboard";
import { DomainsAdmin } from "./DomainsAdmin";
import { GroupsAdmin } from "./GroupsAdmin";
import { ListsAdmin } from "./ListsAdmin";
import { currentAdminSection } from "./AdminNav";
import { usePermissions } from "./usePermissions";
@@ -12,6 +13,7 @@ const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
dashboard: () => <AdminDashboard />,
accounts: (id) => <AccountsAdmin selectedId={id} />,
groups: (id) => <GroupsAdmin selectedId={id} />,
lists: (id) => <ListsAdmin selectedId={id} />,
domains: (id) => <DomainsAdmin selectedId={id} />,
};
+291
View File
@@ -0,0 +1,291 @@
import { useEffect, useMemo, useState } from "react";
import { Plus, Search, Trash2, X } from "lucide-react";
import { can } from "@/lib/adminAccess";
import { aliasList, describeDirectoryError, type EmailAlias } from "@/lib/adminDirectory";
import { createList, destroyList, parseAddresses, recipientsPatch, updateList, type DirectoryList } from "@/lib/adminLists";
import { plural, t } from "@/lib/i18n";
import { Dialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { Aliases } from "./AccountSheet";
import type { DirectoryContext } from "./directoryContext";
import { usePermissions } from "./usePermissions";
interface Props {
/** Null to create one. */
list: DirectoryList | null;
ctx: DirectoryContext;
onClose: () => void;
onChanged: () => void;
onCreated: (id: string) => void;
onDeleted: () => void;
}
/** Past this many, the recipients get a filter of their own. */
const FILTER_FROM = 12;
/**
* One mailing list, opened beside the table.
*
* Everything on it saves together, recipients included: they are a property of
* the list itself, unlike a group's members. What Save sends for them is only
* the addresses added and removed, one pointer each, so a recipient added
* elsewhere while this was open is not lost by saving it.
*/
export function ListSheet({ list, ctx, onClose, onChanged, onCreated, onDeleted }: Props) {
const perms = usePermissions();
const creating = list === null;
const editable = creating ? can(perms, "MailingList", "Create") : can(perms, "MailingList", "Update");
const original = useMemo(() => Object.keys(list?.recipients ?? {}), [list]);
const [description, setDescription] = useState(list?.description ?? "");
const [name, setName] = useState("");
const [domainId, setDomainId] = useState(ctx.domains[0]?.id ?? "");
const [recipients, setRecipients] = useState<string[]>(original);
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(list?.aliases ?? {}));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
}, [ctx.domains, domainId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
const address = list?.emailAddress ?? `${name}@${domainName(domainId)}`;
const save = async () => {
setBusy(true);
setError(null);
try {
if (!list) {
if (!name.trim() || !domainId) {
setError(t("A list needs an address."));
return;
}
const id = await createList({ name, domainId, description, recipients });
toast.success(t("Created {address}", { address }));
onCreated(id);
return;
}
const patch: Record<string, unknown> = { ...recipientsPatch(original, recipients) };
if ((list.description ?? "") !== description) patch.description = description.trim() || null;
if (JSON.stringify(aliasList(Object.values(list.aliases ?? {}))) !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
if (!Object.keys(patch).length) {
onClose();
return;
}
await updateList(list.id, patch);
toast.success(t("Saved {address}", { address }));
onChanged();
} catch (err) {
setError(describeDirectoryError(err, "list"));
} finally {
setBusy(false);
}
};
return (
<aside className="admin-sheet" aria-label={creating ? t("New mailing list") : address}>
<div className="admin-sheet-head">
<div className="grow">
<h2 className="truncate">{creating ? t("New mailing list") : list.description || list.name}</h2>
{list && <div className="hint truncate notranslate" translate="no">{list.emailAddress}</div>}
</div>
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
<X size={20} />
</button>
</div>
<div className="admin-sheet-body">
{!creating && !editable && <p className="admin-notice">{t("Your role lets you view mailing lists but not change them.")}</p>}
<h3>{t("Profile")}</h3>
<div className="field">
<label htmlFor="admin-list-description">{t("Display name")}</label>
<input id="admin-list-description" className="input" value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
</div>
{creating && (
<div className="field">
<label htmlFor="admin-list-name">{t("Address")}</label>
<div className="row admin-address">
<input id="admin-list-name" className="input" value={name} autoComplete="off" spellCheck={false} onChange={(e) => setName(e.target.value.trim().toLowerCase())} />
<span className="muted">@</span>
<select className="input" aria-label={t("Domain")} value={domainId} onChange={(e) => setDomainId(e.target.value)}>
{ctx.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
{!ctx.domains.length && <span className="hint">{t("No domains are available to create a list on.")}</span>}
</div>
)}
<h3>{t("Recipients")}</h3>
<Recipients recipients={recipients} setRecipients={setRecipients} editable={editable} />
{!creating && (
<>
<h3>{t("Other addresses")}</h3>
<Aliases
aliases={aliases}
setAliases={setAliases}
editable={editable}
domains={ctx.domains}
defaultDomain={list.domainId}
domainName={domainName}
hint={t("Mail to these addresses goes to the list too. Changes apply when you save.")}
/>
</>
)}
{error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && can(perms, "MailingList", "Destroy") && <DeleteList list={list} onDeleted={onDeleted} />}
</div>
{editable && (
<div className="admin-sheet-foot">
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
<button className="btn btn-primary" disabled={busy || (creating && (!name || !domainId))} onClick={() => void save()}>
{creating ? t("Create list") : t("Save changes")}
</button>
</div>
)}
</aside>
);
}
function Recipients({ recipients, setRecipients, editable }: { recipients: string[]; setRecipients: (r: string[]) => void; editable: boolean }) {
const [text, setText] = useState("");
const [filter, setFilter] = useState("");
const [rejected, setRejected] = useState<string[]>([]);
const add = () => {
const { addresses, rejected: bad } = parseAddresses(text);
const have = new Set(recipients.map((r) => r.toLowerCase()));
const fresh = addresses.filter((a) => !have.has(a.toLowerCase()));
if (fresh.length) setRecipients([...recipients, ...fresh]);
setRejected(bad);
// Keep what could not be read in the box, so it can be corrected.
setText(bad.join(", "));
};
const needle = filter.trim().toLowerCase();
const shown = needle ? recipients.filter((r) => r.toLowerCase().includes(needle)) : recipients;
return (
<div>
<p className="hint" style={{ marginTop: 0 }}>{plural(recipients.length, { one: "{n} recipient", other: "{n} recipients" })}</p>
{recipients.length > FILTER_FROM && (
<label className="admin-search admin-recipient-filter">
<Search size={16} aria-hidden="true" />
<input className="input" type="search" value={filter} placeholder={t("Filter recipients")} aria-label={t("Filter recipients")} onChange={(e) => setFilter(e.target.value)} />
</label>
)}
{recipients.length > 0 && (
<div className="row wrap gap-4 admin-recipients">
{shown.map((r) => (
<span key={r.toLowerCase()} className="chip notranslate" translate="no">
{r}
{editable && (
<button className="chip-x" aria-label={t("Remove {address}", { address: r })} onClick={() => setRecipients(recipients.filter((x) => x !== r))}>
<X size={12} />
</button>
)}
</span>
))}
{!shown.length && <span className="hint">{t("No recipients match")}</span>}
</div>
)}
{editable && (
<>
<div className="row mt-8">
<input
className="input grow"
aria-label={t("Add recipients")}
placeholder={t("Addresses, separated by commas")}
value={text}
spellCheck={false}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
/>
<button className="btn btn-sm" onClick={add} disabled={!text.trim()}>
<Plus size={14} /> {t("Add")}
</button>
</div>
{rejected.length > 0 && (
<p className="admin-notice warn" role="alert">{t("Not added, as they aren't addresses: {items}", { items: rejected.join(", ") })}</p>
)}
<p className="hint">{t("Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.")}</p>
</>
)}
</div>
);
}
function DeleteList({ list, onDeleted }: { list: DirectoryList; onDeleted: () => void }) {
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const address = list.emailAddress ?? list.name;
return (
<>
<h3>{t("Delete")}</h3>
<div className="admin-danger">
<p>{t("Mail to this address stops being passed on. The recipients' own mail is untouched.")}</p>
<button className="btn btn-sm admin-danger-btn" onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
<Trash2 size={14} /> {t("Delete list…")}
</button>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={t("Delete {address}?", { address })}
size="sm"
footer={
<>
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || typed.trim().toLowerCase() !== address.toLowerCase()}
onClick={async () => {
setBusy(true);
setError(null);
try {
await destroyList(list.id);
toast.success(t("Deleted {address}", { address }));
setOpen(false);
onDeleted();
} catch (err) {
setError(describeDirectoryError(err, "list"));
} finally {
setBusy(false);
}
}}
>
{t("Delete list")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>{t("Mail to this address is no longer passed on to anyone. It can't be undone.")}</p>
<div className="field">
<label htmlFor="admin-list-delete-confirm">{t("Type {address} to confirm", { address })}</label>
<input id="admin-list-delete-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
</Dialog>
</>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, List, Plus, Search } from "lucide-react";
import { can } from "@/lib/adminAccess";
import { describeDirectoryError, listDomains, type DirectoryDomain } from "@/lib/adminDirectory";
import { getLists, queryLists, type DirectoryList } from "@/lib/adminLists";
import { plural, t } from "@/lib/i18n";
import { Empty, Spinner } from "@/ui/misc";
import { useSession } from "@/store/session";
import { STALWART_CAP } from "@/jmap/client";
import { usePermissions } from "./usePermissions";
import type { DirectoryContext } from "./directoryContext";
import { ListSheet } from "./ListSheet";
const PAGE_SIZE = 50;
/**
* Mailing lists: an address that passes mail on to others.
*
* The same shape as Accounts and Groups -- search, fifty to a page, a panel --
* with the number of recipients where they have a role or members.
*/
export function ListsAdmin({ selectedId }: { selectedId?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
const session = useSession((s) => s.session);
const [text, setText] = useState("");
const [query, setQuery] = useState("");
const [position, setPosition] = useState(0);
const [page, setPage] = useState<{ lists: DirectoryList[]; total: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
const [serverDomains, setServerDomains] = useState<DirectoryDomain[] | null>(null);
const [loose, setLoose] = useState<DirectoryList | null>(null);
useEffect(() => {
const id = window.setTimeout(() => {
setQuery(text);
setPosition(0);
}, 250);
return () => window.clearTimeout(id);
}, [text]);
useEffect(() => {
let cancelled = false;
setError(null);
void (async () => {
try {
const q = await queryLists({ text: query, position, limit: PAGE_SIZE });
const lists = await getLists(q.ids);
if (!cancelled) setPage({ lists, total: q.total });
} catch (err) {
if (!cancelled) {
setPage({ lists: [], total: 0 });
setError(describeDirectoryError(err, "list"));
}
}
})();
return () => {
cancelled = true;
};
}, [query, position, reload]);
useEffect(() => {
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) void listDomains().then(setServerDomains, () => setServerDomains(null));
}, [perms, reload]);
useEffect(() => {
if (!selectedId || selectedId === "new" || page?.lists.some((l) => l.id === selectedId)) {
setLoose(null);
return;
}
let cancelled = false;
void getLists([selectedId]).then(
([l]) => { if (!cancelled) setLoose(l ?? null); },
() => { if (!cancelled) setLoose(null); },
);
return () => {
cancelled = true;
};
}, [selectedId, page]);
const ctx: DirectoryContext = useMemo(() => {
const seen = new Map<string, DirectoryDomain>();
for (const l of page?.lists ?? []) {
const domain = l.emailAddress?.split("@")[1];
if (domain && !seen.has(l.domainId)) seen.set(l.domainId, { id: l.domainId, name: domain });
}
const ownId = session?.primaryAccounts?.[STALWART_CAP];
return {
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
roles: null,
groups: new Map(),
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
};
}, [page, serverDomains, session]);
const selected = selectedId && selectedId !== "new" ? (page?.lists.find((l) => l.id === selectedId) ?? loose) : null;
const close = () => navigate("/admin/lists");
const changed = () => setReload((n) => n + 1);
return (
<div>
<div className="admin-head">
<div className="grow">
<h1>{t("Mailing lists")}</h1>
<p className="lead">{t("Addresses that pass mail on to everyone on them.")}</p>
</div>
{can(perms, "MailingList", "Create") && (
<button className="btn btn-primary" onClick={() => navigate("/admin/lists/new")}>
<Plus size={16} /> {t("New mailing list")}
</button>
)}
</div>
<div className="admin-toolbar">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input className="input" type="search" value={text} onChange={(e) => setText(e.target.value)} placeholder={t("Search by name or address")} aria-label={t("Search mailing lists")} />
</label>
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{page === null ? (
<Spinner />
) : page.lists.length === 0 ? (
!error && (
<Empty icon={<List size={32} />} title={query ? t("No mailing lists match") : t("No mailing lists yet")}>
{query ? t("Nothing on your domains matches “{query}”.", { query }) : undefined}
</Empty>
)
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>{t("Mailing list")}</th>
<th>{t("Recipients")}</th>
<th className="hide-mobile">{t("Other addresses")}</th>
</tr>
</thead>
<tbody>
{page.lists.map((l) => (
<tr
key={l.id}
className={l.id === selectedId ? "selected" : ""}
tabIndex={0}
onClick={() => navigate(`/admin/lists/${l.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/admin/lists/${l.id}`);
}
}}
aria-label={t("Open {address}", { address: l.emailAddress ?? l.name })}
>
<td>
<div className="admin-who-name truncate">{l.description || l.name}</div>
<div className="hint truncate notranslate" translate="no">{l.emailAddress}</div>
</td>
<td className="muted" style={{ fontVariantNumeric: "tabular-nums" }}>{Object.keys(l.recipients ?? {}).length}</td>
<td className="hide-mobile muted">
<span className="truncate admin-groups notranslate" translate="no">{Object.values(l.aliases ?? {}).map((a) => a.name).join(", ") || "—"}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{page.total <= PAGE_SIZE && position === 0 ? (
<p className="hint admin-count">{plural(page.total, { one: "{n} mailing list", other: "{n} mailing lists" })}</p>
) : (
<div className="admin-pager">
<span className="hint">{t("{from}{to} of {total}", { from: position + 1, to: position + page.lists.length, total: page.total })}</span>
<button className="icon-btn sm" aria-label={t("Previous page")} disabled={position === 0} onClick={() => setPosition(Math.max(0, position - PAGE_SIZE))}><ChevronLeft size={18} /></button>
<button className="icon-btn sm" aria-label={t("Next page")} disabled={position + page.lists.length >= page.total} onClick={() => setPosition(position + PAGE_SIZE)}><ChevronRight size={18} /></button>
</div>
)}
</>
)}
{(selectedId === "new" || selected) && (
<ListSheet
key={selectedId}
list={selectedId === "new" ? null : selected}
ctx={ctx}
onClose={close}
onChanged={changed}
onCreated={(id) => {
changed();
navigate(`/admin/lists/${id}`);
}}
onDeleted={() => {
changed();
close();
}}
/>
)}
</div>
);
}
@@ -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();
});
});