Add Mailing lists to Administration
A mailing list is an address that passes mail on to everyone on it. To Stalwart it is its own object, x:MailingList, behind sysMailingList*, so it gets its own section under Directory after Groups: search, fifty to a page with each list's recipient count, and a panel to create, edit and delete one. Recipients are a property of the list, so unlike a group's members they save with the rest of the panel. What Save sends for them is only what was added and removed, one recipients/<address> pointer each -- the patch the live server accepted -- so a recipient added elsewhere while the panel was open is not taken out. They can be pasted several at a time, from a spreadsheet column, a comma-separated line or Name <address>; anything with an @ that is not an address stays in the box with a note. Past a dozen, a filter narrows them. That is all a list is in Stalwart -- no owners, moderation or posting rules -- so that is all the panel offers. The mock answers x:MailingList with two lists, the recipient set's live shape, and the refusals a wrong address, a clash with an account and a missing permission get. Twenty-five new strings and one plural, in all nine catalogues.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/adminLists";
|
||||
|
||||
describe("a mailing list's recipients", () => {
|
||||
it("are saved as what was added and removed, one pointer each", () => {
|
||||
// The live server adds a set key on `true`, removes it on `null`, and
|
||||
// leaves the rest -- so a recipient added elsewhere meanwhile survives.
|
||||
expect(recipientsPatch(["[email protected]", "[email protected]"], ["[email protected]", "[email protected]"])).toEqual({
|
||||
"recipients/[email protected]": null,
|
||||
"recipients/[email protected]": true,
|
||||
});
|
||||
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
|
||||
});
|
||||
|
||||
it("compare without regard to case, and escape what a pointer cannot hold", () => {
|
||||
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
|
||||
expect(recipientsPatch([], ["odd/[email protected]"])).toEqual({ "recipients/[email protected]": true });
|
||||
});
|
||||
|
||||
it("come out of a paste of names, commas and angle brackets, and keep what isn't an address", () => {
|
||||
expect(parseAddresses('Ada Lovelace <[email protected]>, [email protected]; "Alan" [email protected]\[email protected] mailto:[email protected]')).toEqual({
|
||||
addresses: ["[email protected]", "[email protected]", "[email protected]", "[email protected]"],
|
||||
rejected: [],
|
||||
});
|
||||
expect(parseAddresses("ada@, @example.org, someone@nowhere")).toEqual({ addresses: [], rejected: ["ada@", "@example.org", "someone@nowhere"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the list calls", () => {
|
||||
it("search on text, and leave the filter out when there is none", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
|
||||
await queryLists({ text: " board ", position: 50, limit: 50 });
|
||||
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { filter: { text: "board" }, position: 50, limit: 50, calculateTotal: true });
|
||||
await queryLists({});
|
||||
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { position: 0, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("create one with its recipients as a set", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "l9" } } });
|
||||
expect(await createList({ name: "team", domainId: "d1", description: " ", recipients: ["[email protected]", "[email protected]"] })).toBe("l9");
|
||||
expect(call).toHaveBeenCalledWith("x:MailingList/set", {
|
||||
create: { n: { name: "team", domainId: "d1", description: null, recipients: { "[email protected]": true, "[email protected]": true }, aliases: {} } },
|
||||
});
|
||||
call.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole
|
||||
return perms.has(`sys${object}${op}`);
|
||||
}
|
||||
|
||||
export type AdminSection = "dashboard" | "accounts" | "groups" | "domains";
|
||||
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "domains";
|
||||
|
||||
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
||||
|
||||
@@ -62,6 +62,7 @@ export function adminSections(perms: Permissions): AdminSection[] {
|
||||
if (dashboardCards(perms).length) out.push("dashboard");
|
||||
// Groups are accounts to the server, behind the same two permissions.
|
||||
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts", "groups");
|
||||
if (can(perms, "MailingList", "Query") && can(perms, "MailingList", "Get")) out.push("lists");
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ const VALIDATOR_MESSAGES: Record<string, () => string> = {
|
||||
};
|
||||
|
||||
/** What kind of thing a refusal was about, where the wording has to differ. */
|
||||
export type DirectoryObject = "account" | "domain" | "group";
|
||||
export type DirectoryObject = "account" | "domain" | "group" | "list";
|
||||
|
||||
/**
|
||||
* Say what went wrong in terms of the person's own action, in their language.
|
||||
@@ -276,7 +276,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
||||
? t("Your organisation has reached the number of domains it is allowed.")
|
||||
: object === "group"
|
||||
? t("Your organisation has reached the number of groups it is allowed.")
|
||||
: t("Your organisation has reached the number of accounts it is allowed.");
|
||||
: object === "list"
|
||||
? t("Your organisation has reached the number of mailing lists it is allowed.")
|
||||
: t("Your organisation has reached the number of accounts it is allowed.");
|
||||
case "objectIsLinked":
|
||||
return t("Something still depends on this, so the server kept it.");
|
||||
case "notFound":
|
||||
@@ -284,7 +286,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
||||
? t("This domain no longer exists. Someone may have removed it.")
|
||||
: object === "group"
|
||||
? t("This group no longer exists. Someone may have deleted it.")
|
||||
: t("This account no longer exists. Someone may have deleted it.");
|
||||
: object === "list"
|
||||
? t("This mailing list no longer exists. Someone may have deleted it.")
|
||||
: t("This account no longer exists. Someone may have deleted it.");
|
||||
case "rateLimit":
|
||||
return t("Too many attempts. Please wait a few minutes and try again.");
|
||||
case "tooLarge":
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { DirectoryError, type EmailAlias } from "@/lib/adminDirectory";
|
||||
|
||||
/**
|
||||
* Mailing lists, from Stalwart 0.16's directory.
|
||||
*
|
||||
* A list is its own registry object, `x:MailingList`, behind `sysMailingList*`.
|
||||
* It is an address and the addresses it passes mail on to, and nothing more:
|
||||
* there are no owners, no moderation and no posting policy to set. Shapes, as
|
||||
* the live server answered them (2026-09-15):
|
||||
*
|
||||
* - `recipients` is a set of addresses, `{"[email protected]": true}`, on this
|
||||
* server or anywhere else. One is added with `recipients/<address>: true` and
|
||||
* taken out with `null`, which leaves the rest of the set alone.
|
||||
* - `emailAddress` is computed from `name` and `domainId`, as an account's is.
|
||||
* - The query filters on `text`; the default order is newest first.
|
||||
*/
|
||||
|
||||
export interface DirectoryList {
|
||||
id: string;
|
||||
name: string;
|
||||
domainId: string;
|
||||
emailAddress?: string;
|
||||
description?: string | null;
|
||||
recipients?: Record<string, boolean>;
|
||||
aliases?: Record<string, EmailAlias>;
|
||||
}
|
||||
|
||||
const LIST_PROPERTIES = ["name", "domainId", "emailAddress", "description", "recipients", "aliases"];
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined> & {
|
||||
created?: Record<string, { id: string }>;
|
||||
};
|
||||
|
||||
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const first = Object.values(res[key] ?? {})[0];
|
||||
if (first) throw new DirectoryError(first.type, first.description, first.properties);
|
||||
}
|
||||
|
||||
export async function queryLists(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||
const res = await client.call<{ ids?: string[]; total?: number }>("x:MailingList/query", {
|
||||
...(opts.text?.trim() ? { filter: { text: opts.text.trim() } } : {}),
|
||||
position: opts.position ?? 0,
|
||||
...(opts.limit ? { limit: opts.limit } : {}),
|
||||
calculateTotal: true,
|
||||
});
|
||||
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||
}
|
||||
|
||||
export async function getLists(ids: string[]): Promise<DirectoryList[]> {
|
||||
if (!ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryList[] }>("x:MailingList/get", { ids, properties: LIST_PROPERTIES });
|
||||
const byId = new Map(res.list.map((l) => [l.id, l]));
|
||||
return ids.map((id) => byId.get(id)).filter((l): l is DirectoryList => Boolean(l));
|
||||
}
|
||||
|
||||
export interface NewList {
|
||||
name: string;
|
||||
domainId: string;
|
||||
description: string;
|
||||
recipients: string[];
|
||||
}
|
||||
|
||||
export async function createList(input: NewList): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:MailingList/set", {
|
||||
create: {
|
||||
n: {
|
||||
name: input.name.trim(),
|
||||
domainId: input.domainId,
|
||||
description: input.description.trim() || null,
|
||||
recipients: Object.fromEntries(input.recipients.map((r) => [r, true])),
|
||||
aliases: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the list was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateList(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:MailingList/set", { update: { [id]: patch } });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
export async function destroyList(id: string): Promise<void> {
|
||||
const res = await client.call<SetResponse>("x:MailingList/set", { destroy: [id] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/** An address as one step of a JSON pointer: `~` and `/` escaped, as RFC 6901 has it. */
|
||||
const pointerKey = (address: string) => address.replace(/~/g, "~0").replace(/\//g, "~1");
|
||||
|
||||
/**
|
||||
* The recipient changes between two lists of addresses, one pointer each.
|
||||
*
|
||||
* Only what changed is sent, so a recipient someone else added while this panel
|
||||
* was open is not taken out by saving it. Addresses compare without regard to
|
||||
* case, the way mail is delivered to them.
|
||||
*/
|
||||
export function recipientsPatch(before: readonly string[], after: readonly string[]): Record<string, true | null> {
|
||||
const lower = (list: readonly string[]) => new Map(list.map((a) => [a.toLowerCase(), a]));
|
||||
const was = lower(before);
|
||||
const now = lower(after);
|
||||
const patch: Record<string, true | null> = {};
|
||||
for (const [key, address] of was) if (!now.has(key)) patch[`recipients/${pointerKey(address)}`] = null;
|
||||
for (const [key, address] of now) if (!was.has(key)) patch[`recipients/${pointerKey(address)}`] = true;
|
||||
return patch;
|
||||
}
|
||||
|
||||
/** A plausible address: one @, something either side, a dot in the domain. Stalwart has the last word. */
|
||||
export function looksLikeAddress(value: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Addresses out of whatever was typed or pasted: a line from a spreadsheet, a
|
||||
* list separated by commas, `Name <address>`. Words with no @ in them are the
|
||||
* names around the addresses and are passed over; something with an @ that is
|
||||
* not an address is returned, so it can be shown rather than dropped.
|
||||
*/
|
||||
export function parseAddresses(text: string): { addresses: string[]; rejected: string[] } {
|
||||
const addresses: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of text.split(/[\s,;]+/)) {
|
||||
const token = raw.replace(/^["'(<]+|[>"')]+$/g, "").replace(/^mailto:/i, "");
|
||||
if (!token.includes("@")) continue;
|
||||
if (!looksLikeAddress(token)) {
|
||||
rejected.push(token);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(token.toLowerCase())) continue;
|
||||
seen.add(token.toLowerCase());
|
||||
addresses.push(token);
|
||||
}
|
||||
return { addresses, rejected };
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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 n’existe plus. Quelqu’un l’a peut-être supprimé.",
|
||||
"The server did not say whether the group was created.": "Le serveur n’a 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 d’une 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 n’est 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 s’appliquent à l’enregistrement.",
|
||||
"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 s’appliquent à l’enregistrement.",
|
||||
"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. C’est 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 l’instant",
|
||||
"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 n’existe plus. Quelqu’un l’a peut-être supprimée.",
|
||||
"The server did not say whether the list was created.": "Le serveur n’a 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 d’abord retiré du groupe et perd ce qui était partagé avec lui. Les messages du groupe sont supprimés en arrière-plan, et c’est irréversible.", other: "Ses {n} membres sont d’abord retirés du groupe et perdent ce qui était partagé avec lui. Les messages du groupe sont supprimés en arrière-plan, et c’est 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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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} /> },
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user