diff --git a/FEATURES.md b/FEATURES.md
index 5b15389..ba9d3fa 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -1278,14 +1278,18 @@ under Access:
- **The tenant's role** is the most anyone inside it can be allowed: their own
roles are cut down to it.
- **What it holds** is counted, each against its limit. Stalwart keeps no list
- on the tenant; each account, group, domain, list and role names its tenant,
- so the counts are queries for those.
+ on the tenant; each account, group, domain, list, role and DKIM key names its
+ tenant, so the counts are queries for those. A domain created in a tenant
+ brings its keys with it.
- **Domains** are added to a tenant, or taken out, from its panel. Only a domain
in no tenant can be added, and the accounts already on it stay where they
- are.
+ are. A domain comes out only once none of the tenant's accounts are on it —
+ Stalwart would allow it, and strand them.
- **An account's tenant** is chosen on the account's own panel, which is how a
tenant gets its first administrator: an Administrator inside a tenant
- administers that tenant.
+ administers that tenant. Stalwart puts something in a tenant only on a domain
+ in that tenant, so the choice is between no tenant and the domain's own, and
+ a new account starts in its domain's tenant.
- **Delete** is offered once the tenant holds nothing.
Only an administrator outside every tenant can put anything into one; Stalwart
diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md
index 55518c7..d63e06a 100644
--- a/KNOWN-ISSUES.md
+++ b/KNOWN-ISSUES.md
@@ -86,7 +86,15 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
**The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs towards refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` has not been fetched through ihasmail's server on production** — the route sends the session's Basic credential, which reaches every other endpoint, and the source serves the schema to any signed-in account; until it is seen working there the picker's fallback is a notice that the list could not be loaded.
-- **Tenants were built from the 0.16.22 source, its schema and the mock; nothing about them has been written on a live server yet.** Production is Enterprise with no tenants. Read from source: `x:Tenant` is `name`, `logo`, `roles`, `permissions`, `quotas` (a map of `TenantStorageQuota` names) and computed `usedDiskQuota`; membership is `memberTenantId` on accounts, groups, domains, lists, roles and DKIM keys; only a caller outside every tenant may set it (`set.rs` passes `can_set_tenant` only when the token has no tenant, and anyone else gets "Cannot modify memberTenantId property"); a tenant administrator's queries are scoped to the tenant. Assumed, not confirmed: that a tenant still named by anything is refused as `objectIsLinked` — the panel offers the delete only once its counts are all zero, so this matters only if a count cannot be read.
+- **Tenants were built from the 0.16.22 source, its schema and the mock, then tried on the live server (2026-09-15)** with throwaway `ihasmail-tenant-test` tenants, a throwaway role, two throwaway lists and a throwaway domain, `ihasmail-tenant-test.ttlhost.com`, all removed. The live run changed the design twice:
+
+ - **A tenant is created and edited as built**: `name`, `logo`, `roles`, `permissions`, `quotas`; `quotas/` pointers, a logo and a rename in one update; an unknown quota name is `invalidPatch`.
+ - **Something in a tenant has to be on a domain in that tenant.** A list in the tenant on a domain in none was refused, `invalidForeignKey` with `objectId` `{"object": "Domain", …}`; the same list on a domain created in the tenant was accepted — and so was a list in *no* tenant on that domain. **So an account's tenant choice offers only its domain's tenant**, and a new account starts in the tenant of the domain it is made on.
+ - **A domain created in a tenant puts its DKIM keys in the tenant too**, and they stay there. They count against `maxDkimKeys` and keep the tenant from being deleted, so they are counted with everything else.
+ - **Stalwart lets a domain leave a tenant while the tenant still has things on it**, leaving them in a tenant on a domain outside it. **The panel refuses to take a domain out while any of the tenant's accounts are on it.** Mailing lists cannot be filtered by domain, so a list is not checked.
+ - **A tenant still holding anything is kept**: `objectIsLinked`, `objectId` `{"object": "Tenant", …}`, `linkedObjects` naming a role, a list and DKIM keys. A role set to `memberTenantId: null` left it, after which the tenant was deleted.
+
+ Still from source only: that only a caller outside every tenant may set `memberTenantId` (`set.rs` passes `can_set_tenant` only when the token has no tenant), and that a tenant administrator's queries are scoped to the tenant. On a server that does not report Enterprise the Tenants page is only its notice.
- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalogue's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart.
diff --git a/server/src/mock/directory.test.ts b/server/src/mock/directory.test.ts
index ea2be2f..457707a 100644
--- a/server/src/mock/directory.test.ts
+++ b/server/src/mock/directory.test.ts
@@ -286,3 +286,17 @@ test("a tenant administrator cannot move anything into a tenant", () => {
assert.equal(r.notUpdated?.d4?.type, "invalidPatch");
assert.match(r.notUpdated!.d4!.description, /memberTenantId/);
});
+
+test("something in a tenant has to be on a domain in it, and something in none may be anywhere", () => {
+ const dir = make("admin");
+ const outside = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "stray", domainId: "d1", memberTenantId: "t1" } } }) as { notCreated?: Record };
+ assert.equal(outside.notCreated?.n?.type, "invalidForeignKey");
+ assert.equal(outside.notCreated!.n!.objectId.object, "Domain");
+ const inside = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "team", domainId: "d3", memberTenantId: "t1" } } }) as { created?: Record };
+ assert.ok(inside.created?.n?.id);
+ const none = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "open", domainId: "d3" } } }) as { created?: Record };
+ assert.ok(none.created?.n?.id);
+ const [someone] = (dir.handlers["x:Account/query"]!({ filter: { "@type": "User", domainId: "d1" } }) as { ids: string[] }).ids;
+ const move = dir.handlers["x:Account/set"]!({ update: { [someone!]: { memberTenantId: "t1" } } }) as { notUpdated?: Record };
+ assert.equal(move.notUpdated?.[someone!]?.type, "invalidForeignKey");
+});
diff --git a/server/src/mock/directory.ts b/server/src/mock/directory.ts
index db5639a..03e2b5c 100644
--- a/server/src/mock/directory.ts
+++ b/server/src/mock/directory.ts
@@ -231,6 +231,17 @@ export function createDirectory(opts: Options) {
{ id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: { maxAccounts: 25, maxDomains: 2, maxDiskQuota: 50 * GIB }, createdAt: "2026-07-01T09:00:00Z" },
];
const tenantUsage = (id: string) => accounts.filter((x) => x.memberTenantId === id).reduce((n, x) => n + Number(x.usedDiskQuota ?? 0), 0);
+ /**
+ * Something in a tenant has to be on a domain in that tenant; something in no
+ * tenant may be on anyone's domain. Both as the live server answered
+ * (2026-09-15), including the shape of the refusal.
+ */
+ const domainTenantRefused = (o: Obj): Obj | null => {
+ const tenant = o.memberTenantId ?? null;
+ const domain = domains.find((d) => d.id === o.domainId);
+ if (!tenant || !domain || (domain.memberTenantId ?? null) === tenant) return null;
+ return { type: "invalidForeignKey", objectId: { object: "Domain", id: domain.id } };
+ };
/** Only an administrator outside every tenant may put things in one; Stalwart refuses anyone else. */
const tenantRefused = (patch: Obj): Obj | null =>
"memberTenantId" in patch && opts.role !== "admin" ? setError("invalidPatch", "Cannot modify memberTenantId property", ["memberTenantId"]) : null;
@@ -355,7 +366,7 @@ export function createDirectory(opts: Options) {
const refused = grantRefused(o.roles);
if (refused) { notCreated[cid] = setError("forbidden", refused); continue; }
if (o.memberTenantId) {
- const refusedTenant = tenantRefused(o);
+ const refusedTenant = tenantRefused(o) ?? domainTenantRefused(o);
if (refusedTenant) { notCreated[cid] = refusedTenant; continue; }
}
const password = Object.values((o.credentials as Obj) ?? {})[0] as Obj | undefined;
@@ -389,6 +400,7 @@ export function createDirectory(opts: Options) {
if (target["@type"] === "Group") failure = setError("invalidProperties", "Groups cannot be members of other groups.", ["memberGroupIds"]);
else if (Object.keys((next.memberGroupIds as Obj) ?? {}).some((g) => accounts.find((x) => x.id === g)?.["@type"] !== "Group")) failure = setError("invalidForeignKey", "Group does not exist.", ["memberGroupIds"]);
}
+ if (!failure && "memberTenantId" in patch) failure = domainTenantRefused(next);
if (!failure && ("roles" in patch || "permissions" in patch)) {
const refused = grantRefused(next.roles);
if (refused) failure = setError("forbidden", refused);
@@ -477,7 +489,8 @@ export function createDirectory(opts: Options) {
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:DkimSignature/get": get(dkimKeys, "sysDkimSignatureGet"),
- "x:DkimSignature/query": query(() => dkimKeys, "sysDkimSignatureQuery", ["domainId", "memberTenantId"], (o, f) => f.domainId === undefined || o.domainId === f.domainId),
+ "x:DkimSignature/query": query(() => dkimKeys, "sysDkimSignatureQuery", ["domainId", "memberTenantId"], (o, f) =>
+ (f.domainId === undefined || o.domainId === f.domainId) && (f.memberTenantId === undefined || (o.memberTenantId ?? null) === f.memberTenantId)),
"x:DkimSignature/set": (a) => {
const destroyed: string[] = [];
for (const id of (a.destroy as string[]) ?? []) {
@@ -526,10 +539,10 @@ export function createDirectory(opts: Options) {
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);
+ const failure = check(o) ?? (o.memberTenantId ? (tenantRefused(o) ?? domainTenantRefused(o)) : null);
if (failure) { notCreated[cid] = failure; continue; }
const id = `l${counter++}`;
- lists.push({ ...o, id, memberTenantId: null });
+ lists.push({ memberTenantId: null, ...o, id });
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
}
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
diff --git a/web/src/lib/__tests__/adminTenants.test.ts b/web/src/lib/__tests__/adminTenants.test.ts
index 6e8b6a2..b865855 100644
--- a/web/src/lib/__tests__/adminTenants.test.ts
+++ b/web/src/lib/__tests__/adminTenants.test.ts
@@ -21,7 +21,7 @@ describe("what a tenant holds", () => {
if (method === "x:Role/query") throw new Error("forbidden");
return { total: method === "x:Account/query" && f["@type"] === "Group" ? 2 : 1 };
});
- expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1 });
+ expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1, dkimKeys: 1 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberTenantId: "t1" }, limit: 0, calculateTotal: true });
expect(call).toHaveBeenCalledWith("x:Domain/query", { filter: { memberTenantId: "t1" }, limit: 0, calculateTotal: true });
call.mockRestore();
diff --git a/web/src/lib/adminDirectory.ts b/web/src/lib/adminDirectory.ts
index 6cc1700..a11a311 100644
--- a/web/src/lib/adminDirectory.ts
+++ b/web/src/lib/adminDirectory.ts
@@ -63,6 +63,8 @@ export interface DirectoryAccount {
export interface DirectoryDomain {
id: string;
name: string;
+ /** The tenant the domain is in: an account can be in a tenant only on one of its domains. */
+ memberTenantId?: string | null;
}
const ACCOUNT_PROPERTIES = [
@@ -121,7 +123,7 @@ async function all(object: "Domain" | "Role", properties: string[]): Promise<
return res.list;
}
-export const listDomains = () => all("Domain", ["name"]);
+export const listDomains = () => all("Domain", ["name", "memberTenantId"]);
export const listRoles = () => all("Role", ["description", "enabledPermissions", "roleIds"]);
export async function listGroups(): Promise {
diff --git a/web/src/lib/adminTenants.ts b/web/src/lib/adminTenants.ts
index a85baa3..31a2a1c 100644
--- a/web/src/lib/adminTenants.ts
+++ b/web/src/lib/adminTenants.ts
@@ -47,6 +47,8 @@ export const TENANT_MEMBERS = [
{ key: "lists", method: "x:MailingList/query", filter: {}, quota: "maxMailingLists" },
{ key: "domains", method: "x:Domain/query", filter: {}, quota: "maxDomains" },
{ key: "roles", method: "x:Role/query", filter: {}, quota: "maxRoles" },
+ // A domain's keys join the tenant it was created in, and keep it there.
+ { key: "dkimKeys", method: "x:DkimSignature/query", filter: {}, quota: "maxDkimKeys" },
] as const;
export type TenantMemberKind = (typeof TENANT_MEMBERS)[number]["key"];
@@ -118,6 +120,18 @@ export async function tenantDomains(tenantId: string): Promise<{ inTenant: Array
};
}
+/**
+ * How many of a tenant's accounts and groups are on a domain.
+ *
+ * Stalwart lets a domain leave a tenant while the tenant still has accounts on
+ * it (live, 2026-09-15), leaving them in a tenant on a domain outside it --
+ * which it refuses to create. The panel asks this before it offers the move.
+ */
+export async function tenantAccountsOnDomain(tenantId: string, domainId: string): Promise {
+ const res = await client.call<{ total?: number }>("x:Account/query", { filter: { domainId, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
+ return res.total ?? 0;
+}
+
/** Put a domain in a tenant, or take it out with null. */
export async function setDomainTenant(domainId: string, tenantId: string | null): Promise {
const res = await client.call("x:Domain/set", { update: { [domainId]: { memberTenantId: tenantId } } });
diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts
index 0f0df54..60fccfd 100644
--- a/web/src/locales/de.ts
+++ b/web/src/locales/de.ts
@@ -261,7 +261,7 @@ export const catalog: Catalog = {
"The server did not say whether the role was created.": "Der Server hat nicht mitgeteilt, ob die Rolle angelegt wurde.",
"No tenant": "Kein Mandant",
"You can't move your own account into a tenant.": "Sie können Ihr eigenes Konto nicht in einen Mandanten verschieben.",
- "An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Ein Konto in einem Mandanten ist durch dessen Rolle begrenzt und zählt zu dessen Limits, und Administrator bedeutet Administrator dieses Mandanten.",
+ "An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Ein Konto kann in dem Mandanten sein, in dem seine Domain ist. In einem Mandanten ist es durch dessen Rolle begrenzt und zählt zu dessen Limits, und Administrator bedeutet Administrator dieses Mandanten.",
"Tenants": "Mandanten",
"Storage in GB": "Speicher in GB",
"Default tenant roles": "Standardrollen für Mandanten",
@@ -284,13 +284,13 @@ export const catalog: Catalog = {
"Take {domain} out of the tenant": "{domain} aus dem Mandanten entfernen",
"No domains in this tenant yet": "Noch keine Domains in diesem Mandanten",
"Domain to add": "Hinzuzufügende Domain",
- "Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Nur Domains ohne Mandanten können hinzugefügt werden. Die Konten auf einer Domain bleiben, wo sie sind; verschieben Sie jedes in seinem eigenen Bereich.",
+ "Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Nur Domains ohne Mandanten können hinzugefügt werden, und die Konten darauf bleiben, wo sie sind. Eine Domain kann erst entfernt werden, wenn keines der Konten dieses Mandanten mehr darauf ist.",
"An empty tenant can be deleted.": "Ein leerer Mandant kann gelöscht werden.",
"Delete tenant…": "Mandant löschen…",
"Still holds {things}. Move them out first.": "Enthält noch {things}. Verschieben Sie diese zuerst.",
"Delete tenant": "Mandant löschen",
"Separate organisations on one server, each with its own people, domains and limits.": "Getrennte Organisationen auf einem Server, jede mit eigenen Personen, Domains und Limits.",
- "Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Mandanten sind eine Funktion von Stalwart Enterprise. Dieser Server meldet kein Enterprise, daher hat jeder in einem Mandanten nur die Berechtigungen eines normalen Benutzers.",
+ "Tenants are a Stalwart Enterprise feature.": "Mandanten sind eine Funktion von Stalwart Enterprise.",
"Search tenants": "Mandanten durchsuchen",
"No tenants match": "Keine passenden Mandanten",
"No tenants yet": "Noch keine Mandanten",
@@ -1716,6 +1716,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { one: "Gewährt {n} Berechtigung", other: "Gewährt {n} Berechtigungen" },
"{n} roles": { one: "{n} Rolle", other: "{n} Rollen" },
"{n} tenants": { one: "{n} Mandant", other: "{n} Mandanten" },
+ "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} Konto dieses Mandanten ist noch auf {domain}. Verschieben oder löschen Sie es, bevor Sie die Domain entfernen.", other: "{n} Konten dieses Mandanten sind noch auf {domain}. Verschieben oder löschen Sie sie, bevor Sie die Domain entfernen." },
"{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 ────────────────────────────────────────────────
diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts
index a154437..0015cca 100644
--- a/web/src/locales/es.ts
+++ b/web/src/locales/es.ts
@@ -253,7 +253,7 @@ export const catalog: Catalog = {
"The server did not say whether the role was created.": "El servidor no indicó si el rol se creó.",
"No tenant": "Sin inquilino",
"You can't move your own account into a tenant.": "No puede mover su propia cuenta a un inquilino.",
- "An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Una cuenta de un inquilino está limitada por el rol del inquilino y cuenta para sus límites, y Administrador significa administrador de ese inquilino.",
+ "An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Una cuenta puede estar en el inquilino en el que está su dominio. En un inquilino está limitada por el rol del inquilino y cuenta para sus límites, y Administrador significa administrador de ese inquilino.",
"Tenants": "Inquilinos",
"Storage in GB": "Almacenamiento en GB",
"Default tenant roles": "Roles de inquilino predeterminados",
@@ -276,13 +276,13 @@ export const catalog: Catalog = {
"Take {domain} out of the tenant": "Quitar {domain} del inquilino",
"No domains in this tenant yet": "Aún no hay dominios en este inquilino",
"Domain to add": "Dominio para añadir",
- "Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Solo se pueden añadir dominios que no estén en ningún inquilino. Las cuentas que ya están en un dominio se quedan donde están; mueva cada una desde su propio panel.",
+ "Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Solo se pueden añadir dominios que no estén en ningún inquilino, y las cuentas que ya están en uno se quedan donde están. Un dominio solo se puede quitar cuando ninguna cuenta de este inquilino esté en él.",
"An empty tenant can be deleted.": "Un inquilino vacío se puede eliminar.",
"Delete tenant…": "Eliminar inquilino…",
"Still holds {things}. Move them out first.": "Aún tiene {things}. Muévalos primero.",
"Delete tenant": "Eliminar inquilino",
"Separate organisations on one server, each with its own people, domains and limits.": "Organizaciones separadas en un mismo servidor, cada una con sus propias personas, dominios y límites.",
- "Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Los inquilinos son una función de Stalwart Enterprise. Este servidor no indica Enterprise, así que cualquiera dentro de un inquilino solo tiene los permisos de un usuario normal.",
+ "Tenants are a Stalwart Enterprise feature.": "Los inquilinos son una función de Stalwart Enterprise.",
"Search tenants": "Buscar inquilinos",
"No tenants match": "Ningún inquilino coincide",
"No tenants yet": "Aún no hay inquilinos",
@@ -1689,6 +1689,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { one: "Concede {n} permiso", other: "Concede {n} permisos" },
"{n} roles": { one: "{n} rol", other: "{n} roles" },
"{n} tenants": { one: "{n} inquilino", other: "{n} inquilinos" },
+ "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} cuenta de este inquilino sigue en {domain}. Muévala o elimínela antes de quitar el dominio.", other: "{n} cuentas de este inquilino siguen en {domain}. Muévalas o elimínelas antes de quitar el dominio." },
"{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 ────────────────────────────────────────────────
diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts
index 00e5bfc..0dfc0b6 100644
--- a/web/src/locales/fr.ts
+++ b/web/src/locales/fr.ts
@@ -258,7 +258,7 @@ export const catalog: Catalog = {
"The server did not say whether the role was created.": "Le serveur n’a pas indiqué si le rôle a été créé.",
"No tenant": "Aucun locataire",
"You can't move your own account into a tenant.": "Vous ne pouvez pas déplacer votre propre compte dans un locataire.",
- "An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Un compte dans un locataire est limité par le rôle du locataire et compte dans ses limites, et Administrateur signifie administrateur de ce locataire.",
+ "An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Un compte peut être dans le locataire où se trouve son domaine. Dans un locataire, il est limité par le rôle du locataire et compte dans ses limites, et Administrateur signifie administrateur de ce locataire.",
"Tenants": "Locataires",
"Storage in GB": "Stockage en Go",
"Default tenant roles": "Rôles de locataire par défaut",
@@ -281,13 +281,13 @@ export const catalog: Catalog = {
"Take {domain} out of the tenant": "Retirer {domain} du locataire",
"No domains in this tenant yet": "Aucun domaine dans ce locataire pour l’instant",
"Domain to add": "Domaine à ajouter",
- "Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Seuls les domaines hors de tout locataire peuvent être ajoutés. Les comptes déjà sur un domaine restent où ils sont ; déplacez chacun depuis son propre panneau.",
+ "Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Seuls les domaines hors de tout locataire peuvent être ajoutés, et les comptes déjà dessus restent où ils sont. Un domaine ne peut être retiré qu’une fois qu’aucun compte de ce locataire n’y est plus.",
"An empty tenant can be deleted.": "Un locataire vide peut être supprimé.",
"Delete tenant…": "Supprimer le locataire…",
"Still holds {things}. Move them out first.": "Contient encore {things}. Déplacez-les d’abord.",
"Delete tenant": "Supprimer le locataire",
"Separate organisations on one server, each with its own people, domains and limits.": "Des organisations distinctes sur un même serveur, chacune avec ses personnes, ses domaines et ses limites.",
- "Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Les locataires sont une fonctionnalité de Stalwart Enterprise. Ce serveur ne se déclare pas Enterprise : toute personne dans un locataire n’a donc que les autorisations d’un utilisateur ordinaire.",
+ "Tenants are a Stalwart Enterprise feature.": "Les locataires sont une fonctionnalité de Stalwart Enterprise.",
"Search tenants": "Rechercher des locataires",
"No tenants match": "Aucun locataire ne correspond",
"No tenants yet": "Aucun locataire pour l’instant",
@@ -1694,6 +1694,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { one: "Accorde {n} autorisation", other: "Accorde {n} autorisations" },
"{n} roles": { one: "{n} rôle", other: "{n} rôles" },
"{n} tenants": { one: "{n} locataire", other: "{n} locataires" },
+ "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} compte de ce locataire est encore sur {domain}. Déplacez-le ou supprimez-le avant de retirer le domaine.", other: "{n} comptes de ce locataire sont encore sur {domain}. Déplacez-les ou supprimez-les avant de retirer le domaine." },
"{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 ────────────────────────────────────────────────
diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts
index 889082e..0422f3e 100644
--- a/web/src/locales/ja.ts
+++ b/web/src/locales/ja.ts
@@ -252,7 +252,7 @@ export const catalog: Catalog = {
"The server did not say whether the role was created.": "ロールが作成されたかどうか、サーバーから返答がありませんでした。",
"No tenant": "テナントなし",
"You can't move your own account into a tenant.": "自分のアカウントをテナントに移すことはできません。",
- "An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "テナント内のアカウントは、テナントのロールによって制限され、テナントの上限に数えられます。また、管理者はそのテナントの管理者を意味します。",
+ "An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "アカウントは、そのドメインが属するテナントにのみ入れられます。テナント内のアカウントは、テナントのロールによって制限され、テナントの上限に数えられます。また、管理者はそのテナントの管理者を意味します。",
"Tenants": "テナント",
"Storage in GB": "ストレージ (GB)",
"Default tenant roles": "テナントの既定ロール",
@@ -275,13 +275,13 @@ export const catalog: Catalog = {
"Take {domain} out of the tenant": "{domain} をテナントから外す",
"No domains in this tenant yet": "このテナントにはまだドメインがありません",
"Domain to add": "追加するドメイン",
- "Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "追加できるのは、どのテナントにも属していないドメインだけです。ドメイン上の既存のアカウントはそのまま残るため、それぞれのパネルから移動してください。",
+ "Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "追加できるのは、どのテナントにも属していないドメインだけで、既存のアカウントはそのまま残ります。ドメインを外せるのは、このテナントのアカウントがそのドメインに 1 つも残っていないときだけです。",
"An empty tenant can be deleted.": "空のテナントは削除できます。",
"Delete tenant…": "テナントを削除…",
"Still holds {things}. Move them out first.": "まだ {things} が含まれています。先に移動してください。",
"Delete tenant": "テナントを削除",
"Separate organisations on one server, each with its own people, domains and limits.": "1 台のサーバー上の別々の組織で、それぞれに利用者、ドメイン、上限があります。",
- "Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "テナントは Stalwart Enterprise の機能です。このサーバーは Enterprise と報告していないため、テナント内の利用者は通常のユーザーの権限しか持ちません。",
+ "Tenants are a Stalwart Enterprise feature.": "テナントは Stalwart Enterprise の機能です。",
"Search tenants": "テナントを検索",
"No tenants match": "一致するテナントはありません",
"No tenants yet": "まだテナントがありません",
@@ -1697,6 +1697,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { other: "{n} 件の権限を付与" },
"{n} roles": { other: "{n} 件のロール" },
"{n} tenants": { other: "{n} 件のテナント" },
+ "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { other: "このテナントのアカウントがまだ {n} 件 {domain} にあります。ドメインを外す前に、移動するか削除してください。" },
"{n} DKIM keys": { other: "{n} 個の DKIM 鍵" },
"{n} other items": { other: "その他 {n} 件" },
// ── Administration ────────────────────────────────────────────────
diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts
index d58273f..ee6f6d3 100644
--- a/web/src/locales/nl.ts
+++ b/web/src/locales/nl.ts
@@ -249,7 +249,7 @@ export const catalog: Catalog = {
"The server did not say whether the role was created.": "De server heeft niet gemeld of de rol is aangemaakt.",
"No tenant": "Geen tenant",
"You can't move your own account into a tenant.": "U kunt uw eigen account niet naar een tenant verplaatsen.",
- "An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Een account in een tenant wordt beperkt door de rol van de tenant en telt mee voor de limieten, en Beheerder betekent beheerder van die tenant.",
+ "An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Een account kan in de tenant zitten waarin zijn domein zit. In een tenant wordt het beperkt door de rol van de tenant en telt het mee voor de limieten, en Beheerder betekent beheerder van die tenant.",
"Tenants": "Tenants",
"Storage in GB": "Opslag in GB",
"Default tenant roles": "Standaardrollen voor tenants",
@@ -272,13 +272,13 @@ export const catalog: Catalog = {
"Take {domain} out of the tenant": "{domain} uit de tenant halen",
"No domains in this tenant yet": "Nog geen domeinen in deze tenant",
"Domain to add": "Toe te voegen domein",
- "Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Alleen domeinen die in geen enkele tenant zitten kunnen worden toegevoegd. De accounts op een domein blijven waar ze zijn; verplaats elk vanuit het eigen paneel.",
+ "Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Alleen domeinen die in geen enkele tenant zitten kunnen worden toegevoegd, en de accounts die er al op staan blijven waar ze zijn. Een domein kan er pas uit als geen enkel account van deze tenant er nog op staat.",
"An empty tenant can be deleted.": "Een lege tenant kan worden verwijderd.",
"Delete tenant…": "Tenant verwijderen…",
"Still holds {things}. Move them out first.": "Bevat nog {things}. Verplaats die eerst.",
"Delete tenant": "Tenant verwijderen",
"Separate organisations on one server, each with its own people, domains and limits.": "Afzonderlijke organisaties op één server, elk met eigen mensen, domeinen en limieten.",
- "Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Tenants zijn een functie van Stalwart Enterprise. Deze server meldt geen Enterprise, dus iedereen in een tenant heeft alleen de rechten van een gewone gebruiker.",
+ "Tenants are a Stalwart Enterprise feature.": "Tenants zijn een functie van Stalwart Enterprise.",
"Search tenants": "Tenants zoeken",
"No tenants match": "Geen tenants gevonden",
"No tenants yet": "Nog geen tenants",
@@ -1685,6 +1685,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { one: "Kent {n} recht toe", other: "Kent {n} rechten toe" },
"{n} roles": { one: "{n} rol", other: "{n} rollen" },
"{n} tenants": { one: "{n} tenant", other: "{n} tenants" },
+ "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} account van deze tenant staat nog op {domain}. Verplaats of verwijder het voordat u het domein eruit haalt.", other: "{n} accounts van deze tenant staan nog op {domain}. Verplaats of verwijder ze voordat u het domein eruit haalt." },
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
// ── Administration ────────────────────────────────────────────────
diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts
index cc2b888..9b7cbc3 100644
--- a/web/src/locales/pt-BR.ts
+++ b/web/src/locales/pt-BR.ts
@@ -256,7 +256,7 @@ export const catalog: Catalog = {
"The server did not say whether the role was created.": "O servidor não informou se a função foi criada.",
"No tenant": "Nenhum locatário",
"You can't move your own account into a tenant.": "Você não pode mover sua própria conta para um locatário.",
- "An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Uma conta em um locatário é limitada pela função do locatário e conta para os limites dele, e Administrador significa administrador desse locatário.",
+ "An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Uma conta pode estar no locatário em que está o seu domínio. Num locatário, ela é limitada pela função do locatário e conta para os limites dele, e Administrador significa administrador desse locatário.",
"Tenants": "Locatários",
"Storage in GB": "Armazenamento em GB",
"Default tenant roles": "Funções padrão de locatário",
@@ -279,13 +279,13 @@ export const catalog: Catalog = {
"Take {domain} out of the tenant": "Retirar {domain} do locatário",
"No domains in this tenant yet": "Nenhum domínio neste locatário ainda",
"Domain to add": "Domínio a adicionar",
- "Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Só podem ser adicionados domínios que não estejam em nenhum locatário. As contas já existentes num domínio ficam onde estão; mova cada uma pelo próprio painel.",
+ "Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Só podem ser adicionados domínios que não estejam em nenhum locatário, e as contas já existentes num deles ficam onde estão. Um domínio só pode ser retirado quando nenhuma conta deste locatário estiver nele.",
"An empty tenant can be deleted.": "Um locatário vazio pode ser excluído.",
"Delete tenant…": "Excluir locatário…",
"Still holds {things}. Move them out first.": "Ainda tem {things}. Mova-os primeiro.",
"Delete tenant": "Excluir locatário",
"Separate organisations on one server, each with its own people, domains and limits.": "Organizações separadas em um mesmo servidor, cada uma com suas próprias pessoas, domínios e limites.",
- "Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Locatários são um recurso do Stalwart Enterprise. Este servidor não informa ser Enterprise, então qualquer pessoa num locatário tem só as permissões de um usuário comum.",
+ "Tenants are a Stalwart Enterprise feature.": "Locatários são um recurso do Stalwart Enterprise.",
"Search tenants": "Pesquisar locatários",
"No tenants match": "Nenhum locatário corresponde",
"No tenants yet": "Nenhum locatário ainda",
@@ -1692,6 +1692,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { one: "Concede {n} permissão", other: "Concede {n} permissões" },
"{n} roles": { one: "{n} função", other: "{n} funções" },
"{n} tenants": { one: "{n} locatário", other: "{n} locatários" },
+ "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} conta deste locatário ainda está em {domain}. Mova-a ou exclua-a antes de retirar o domínio.", other: "{n} contas deste locatário ainda estão em {domain}. Mova-as ou exclua-as antes de retirar o domínio." },
"{n} DKIM keys": { one: "{n} chave DKIM", other: "{n} chaves DKIM" },
"{n} other items": { one: "{n} outro item", other: "{n} outros itens" },
// ── Administration ────────────────────────────────────────────────
diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts
index 2bcf334..6e5fe5c 100644
--- a/web/src/locales/ru.ts
+++ b/web/src/locales/ru.ts
@@ -255,7 +255,7 @@ export const catalog: Catalog = {
"The server did not say whether the role was created.": "Сервер не сообщил, создана ли роль.",
"No tenant": "Без арендатора",
"You can't move your own account into a tenant.": "Нельзя переместить собственную учётную запись в арендатора.",
- "An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Учётная запись арендатора ограничена ролью арендатора и учитывается в его лимитах, а «Администратор» означает администратора этого арендатора.",
+ "An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Учётная запись может быть в том арендаторе, в котором её домен. В арендаторе она ограничена его ролью и учитывается в его лимитах, а «Администратор» означает администратора этого арендатора.",
"Tenants": "Арендаторы",
"Storage in GB": "Хранилище, ГБ",
"Default tenant roles": "Роли арендатора по умолчанию",
@@ -278,13 +278,13 @@ export const catalog: Catalog = {
"Take {domain} out of the tenant": "Убрать {domain} из арендатора",
"No domains in this tenant yet": "У этого арендатора пока нет доменов",
"Domain to add": "Домен для добавления",
- "Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Добавить можно только домены, не принадлежащие ни одному арендатору. Учётные записи на домене остаются на месте; переносите каждую из её собственной панели.",
+ "Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Добавить можно только домены, не принадлежащие ни одному арендатору, а учётные записи на них остаются на месте. Домен можно убрать, только когда на нём не осталось учётных записей этого арендатора.",
"An empty tenant can be deleted.": "Пустого арендатора можно удалить.",
"Delete tenant…": "Удалить арендатора…",
"Still holds {things}. Move them out first.": "Ещё содержит: {things}. Сначала перенесите их.",
"Delete tenant": "Удалить арендатора",
"Separate organisations on one server, each with its own people, domains and limits.": "Отдельные организации на одном сервере, у каждой свои люди, домены и лимиты.",
- "Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Арендаторы — функция Stalwart Enterprise. Этот сервер не сообщает о редакции Enterprise, поэтому у любого в арендаторе только разрешения обычного пользователя.",
+ "Tenants are a Stalwart Enterprise feature.": "Арендаторы — функция Stalwart Enterprise.",
"Search tenants": "Поиск арендаторов",
"No tenants match": "Нет подходящих арендаторов",
"No tenants yet": "Арендаторов пока нет",
@@ -1691,6 +1691,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { one: "Даёт {n} разрешение", few: "Даёт {n} разрешения", many: "Даёт {n} разрешений", other: "Даёт {n} разрешения" },
"{n} roles": { one: "{n} роль", few: "{n} роли", many: "{n} ролей", other: "{n} роли" },
"{n} tenants": { one: "{n} арендатор", few: "{n} арендатора", many: "{n} арендаторов", other: "{n} арендатора" },
+ "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} учётная запись этого арендатора ещё на {domain}. Перенесите или удалите её, прежде чем убирать домен.", few: "{n} учётные записи этого арендатора ещё на {domain}. Перенесите или удалите их, прежде чем убирать домен.", many: "{n} учётных записей этого арендатора ещё на {domain}. Перенесите или удалите их, прежде чем убирать домен.", other: "{n} учётной записи этого арендатора ещё на {domain}. Перенесите или удалите их, прежде чем убирать домен." },
"{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 ────────────────────────────────────────────────
diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts
index ba2eaee..34515d7 100644
--- a/web/src/locales/uk.ts
+++ b/web/src/locales/uk.ts
@@ -249,7 +249,7 @@ export const catalog: Catalog = {
"The server did not say whether the role was created.": "Сервер не повідомив, чи створено роль.",
"No tenant": "Без орендаря",
"You can't move your own account into a tenant.": "Не можна перемістити власний обліковий запис до орендаря.",
- "An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Обліковий запис орендаря обмежений роллю орендаря й зараховується до його лімітів, а «Адміністратор» означає адміністратора цього орендаря.",
+ "An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Обліковий запис може бути в тому орендарі, у якому його домен. В орендарі він обмежений роллю орендаря й зараховується до його лімітів, а «Адміністратор» означає адміністратора цього орендаря.",
"Tenants": "Орендарі",
"Storage in GB": "Сховище, ГБ",
"Default tenant roles": "Ролі орендаря за замовчуванням",
@@ -272,13 +272,13 @@ export const catalog: Catalog = {
"Take {domain} out of the tenant": "Прибрати {domain} з орендаря",
"No domains in this tenant yet": "У цього орендаря поки немає доменів",
"Domain to add": "Домен для додавання",
- "Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Додати можна лише домени, що не належать жодному орендарю. Облікові записи на домені лишаються на місці; переносьте кожен з його власної панелі.",
+ "Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Додати можна лише домени, що не належать жодному орендарю, а облікові записи на них лишаються на місці. Домен можна прибрати, лише коли на ньому не лишилося облікових записів цього орендаря.",
"An empty tenant can be deleted.": "Порожнього орендаря можна видалити.",
"Delete tenant…": "Видалити орендаря…",
"Still holds {things}. Move them out first.": "Ще містить: {things}. Спершу перенесіть їх.",
"Delete tenant": "Видалити орендаря",
"Separate organisations on one server, each with its own people, domains and limits.": "Окремі організації на одному сервері, кожна зі своїми людьми, доменами й лімітами.",
- "Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Орендарі — функція Stalwart Enterprise. Цей сервер не повідомляє про редакцію Enterprise, тож будь-хто в орендарі має лише дозволи звичайного користувача.",
+ "Tenants are a Stalwart Enterprise feature.": "Орендарі — функція Stalwart Enterprise.",
"Search tenants": "Пошук орендарів",
"No tenants match": "Немає відповідних орендарів",
"No tenants yet": "Орендарів поки немає",
@@ -1685,6 +1685,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { one: "Надає {n} дозвіл", few: "Надає {n} дозволи", many: "Надає {n} дозволів", other: "Надає {n} дозволу" },
"{n} roles": { one: "{n} роль", few: "{n} ролі", many: "{n} ролей", other: "{n} ролі" },
"{n} tenants": { one: "{n} орендар", few: "{n} орендарі", many: "{n} орендарів", other: "{n} орендаря" },
+ "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} обліковий запис цього орендаря ще на {domain}. Перенесіть або видаліть його, перш ніж прибирати домен.", few: "{n} облікові записи цього орендаря ще на {domain}. Перенесіть або видаліть їх, перш ніж прибирати домен.", many: "{n} облікових записів цього орендаря ще на {domain}. Перенесіть або видаліть їх, перш ніж прибирати домен.", other: "{n} облікового запису цього орендаря ще на {domain}. Перенесіть або видаліть їх, перш ніж прибирати домен." },
"{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 ────────────────────────────────────────────────
diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts
index a9ab3a9..2d7423a 100644
--- a/web/src/locales/zh-Hans.ts
+++ b/web/src/locales/zh-Hans.ts
@@ -251,7 +251,7 @@ export const catalog: Catalog = {
"The server did not say whether the role was created.": "服务器未说明角色是否已创建。",
"No tenant": "无租户",
"You can't move your own account into a tenant.": "您不能将自己的账户移入租户。",
- "An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "租户中的账户受租户角色限制,并计入租户的限额;管理员指该租户的管理员。",
+ "An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "账户只能位于其域名所属的租户中。在租户中,账户受租户角色限制,并计入租户的限额;管理员指该租户的管理员。",
"Tenants": "租户",
"Storage in GB": "存储 (GB)",
"Default tenant roles": "默认租户角色",
@@ -274,13 +274,13 @@ export const catalog: Catalog = {
"Take {domain} out of the tenant": "将 {domain} 移出租户",
"No domains in this tenant yet": "此租户中还没有域名",
"Domain to add": "要添加的域名",
- "Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "只能添加不属于任何租户的域名。域名上已有的账户保持不变;请在各自的面板中移动。",
+ "Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "只能添加不属于任何租户的域名,其上已有的账户保持不变。只有当此租户在该域名上没有任何账户时,才能将域名移出。",
"An empty tenant can be deleted.": "空租户可以删除。",
"Delete tenant…": "删除租户…",
"Still holds {things}. Move them out first.": "仍包含 {things}。请先将其移出。",
"Delete tenant": "删除租户",
"Separate organisations on one server, each with its own people, domains and limits.": "同一服务器上相互独立的组织,各有自己的成员、域名和限额。",
- "Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "租户是 Stalwart Enterprise 的功能。此服务器未报告为 Enterprise,因此租户中的任何人都只有普通用户的权限。",
+ "Tenants are a Stalwart Enterprise feature.": "租户是 Stalwart Enterprise 的功能。",
"Search tenants": "搜索租户",
"No tenants match": "没有匹配的租户",
"No tenants yet": "还没有租户",
@@ -1696,6 +1696,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { other: "授予 {n} 项权限" },
"{n} roles": { other: "{n} 个角色" },
"{n} tenants": { other: "{n} 个租户" },
+ "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { other: "此租户仍有 {n} 个账户位于 {domain}。移出该域名前,请先移动或删除这些账户。" },
"{n} DKIM keys": { other: "{n} 个 DKIM 密钥" },
"{n} other items": { other: "其他 {n} 项" },
// ── Administration ────────────────────────────────────────────────
diff --git a/web/src/views/admin/AccountSheet.tsx b/web/src/views/admin/AccountSheet.tsx
index 865105d..f01bacb 100644
--- a/web/src/views/admin/AccountSheet.tsx
+++ b/web/src/views/admin/AccountSheet.tsx
@@ -93,6 +93,11 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
}, [ctx.domains, domainId]);
+ // A new account starts in the tenant of the domain it is being made on.
+ useEffect(() => {
+ if (creating) setTenantId(ctx.domains.find((d) => d.id === domainId)?.memberTenantId ?? "");
+ }, [creating, domainId, ctx.domains]);
+
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
@@ -102,6 +107,13 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
}, [onClose]);
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
+ /*
+ * Stalwart refuses an account in a tenant on a domain outside it (live,
+ * 2026-09-15: invalidForeignKey naming the domain), and allows one in no
+ * tenant on a tenant's domain. So the only tenant to offer is the domain's.
+ */
+ const domainTenant = ctx.domains.find((d) => d.id === (account?.domainId ?? domainId))?.memberTenantId ?? null;
+ const tenantName = (id: string) => ctx.tenants?.find((x) => x.id === id)?.name ?? id;
const address = account?.emailAddress ?? `${name}@${domainName(domainId)}`;
const roleOptions = useMemo(() => {
@@ -247,15 +259,19 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
{self ? t("You can't change your own role.") : t("Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.")}
{self ? t("You can't move your own account into a tenant.") : t("An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.")}
+
+ {self
+ ? t("You can't move your own account into a tenant.")
+ : t("An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.")}
+
>
)}
diff --git a/web/src/views/admin/TenantSheet.tsx b/web/src/views/admin/TenantSheet.tsx
index 2c1dd8d..c2699e5 100644
--- a/web/src/views/admin/TenantSheet.tsx
+++ b/web/src/views/admin/TenantSheet.tsx
@@ -10,6 +10,7 @@ import {
drawableLogo,
quotasPatch,
setDomainTenant,
+ tenantAccountsOnDomain,
tenantDomains,
updateTenant,
TENANT_MEMBERS,
@@ -21,7 +22,7 @@ import {
} from "@/lib/adminTenants";
import { formatSize } from "@/lib/format";
import { proxiedImageUrl } from "@/lib/html";
-import { t } from "@/lib/i18n";
+import { plural, t } from "@/lib/i18n";
import { Dialog } from "@/ui/dialog";
import { Spinner } from "@/ui/misc";
import { toast } from "@/ui/toast";
@@ -281,6 +282,16 @@ function TenantDomains({ tenant, canChange, onChanged }: { tenant: DirectoryTena
setBusy(true);
setError(null);
try {
+ if (!into) {
+ const stranded = await tenantAccountsOnDomain(tenant.id, domain.id);
+ if (stranded > 0) {
+ setError(plural(stranded, {
+ one: "{n} account in this tenant is still on {domain}. Move it or delete it before taking the domain out.",
+ other: "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.",
+ }, { domain: domain.name }));
+ return;
+ }
+ }
await setDomainTenant(domain.id, into ? tenant.id : null);
toast.success(into ? t("Added {domain} to {tenant}", { domain: domain.name, tenant: tenant.name }) : t("Took {domain} out of {tenant}", { domain: domain.name, tenant: tenant.name }));
setRevision((n) => n + 1);
@@ -323,7 +334,7 @@ function TenantDomains({ tenant, canChange, onChanged }: { tenant: DirectoryTena
)}
{error &&
{error}
}
-
{t("Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.")}
+
{t("Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.")}
{t("Separate organisations on one server, each with its own people, domains and limits.")}
-
{t("Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.")}