Say every Administration refusal in the reader's language
Stalwart explains a refused change in English, and several of its words reached the page as they were: "Invalid domain name" for a reserved TLD, "Invalid email address" for a catch-all, a grant refusal, and ihasmail's own proxy messages. Every registry error type now has its own message, and a value one of the registry's string validators refused is recognised by the validator's wording and explained again. A domain clash or a missing domain is worded for a domain rather than an account. The one exception is kept on purpose: a password policy's reason follows a translated sentence, because the rule is the server's and dropping it would leave no way to find out why. The mock now refuses a reserved TLD and a catch-all without a domain the way the live server did. KNOWN-ISSUES records the fix, and that the last two live cases -- an administrator-set password and the outranking guard -- held. 15 new strings in all nine catalogues, 3 retired; strings falling back to English stay at 16.
This commit is contained in:
@@ -55,3 +55,45 @@ describe("the account query", () => {
|
||||
call.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart explains a refusal in English, and none of it should reach an
|
||||
* interface in another language as it is. Each case below is a refusal a
|
||||
* live server gave, or one its source says it gives.
|
||||
*/
|
||||
describe("refusals in the reader's language", () => {
|
||||
it("recognises the registry's validators and says it again, without the server's words", () => {
|
||||
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
|
||||
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
|
||||
expect(domain).toMatch(/isn't a valid domain name/);
|
||||
expect(domain).not.toContain("Invalid domain name");
|
||||
expect(describeDirectoryError(new DirectoryError("invalidPatch", "Invalid email address", ["catchAllAddress"]), "domain")).toMatch(/full address/);
|
||||
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Invalid email local part", ["name"]))).toMatch(/before the @/);
|
||||
});
|
||||
|
||||
it("never echoes a description it does not know", () => {
|
||||
const text = describeDirectoryError(new DirectoryError("invalidPatch", "Something only the server would say", ["whatever"]));
|
||||
expect(text).not.toContain("Something only the server would say");
|
||||
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not allowed to do that thing"))).not.toContain("not allowed to do that thing");
|
||||
expect(describeDirectoryError(new DirectoryError("someNewType", "Brand new English"))).not.toContain("Brand new English");
|
||||
});
|
||||
|
||||
it("tells a grant refusal and a directory-backed account apart from a plain no", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not authorized to grant permissions: sysDomainDestroy."))).toMatch(/permissions your own role/);
|
||||
expect(describeDirectoryError(new DirectoryError("forbidden", "Cannot set credentials for accounts in an external directory."))).toMatch(/external directory/);
|
||||
});
|
||||
|
||||
it("words a clash and a missing object for what it was about", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined, ["name"]), "domain")).toMatch(/domain name is already in use/);
|
||||
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined))).toMatch(/address is already in use/);
|
||||
expect(describeDirectoryError(new DirectoryError("notFound", undefined), "domain")).toMatch(/domain no longer exists/);
|
||||
});
|
||||
|
||||
it("explains ihasmail's own refusals by their code, not their English message", () => {
|
||||
const own = { status: 403, code: "administration_needs_own_device", message: "Administration is only available when signed in on a device marked as your own (x:Account/query)." };
|
||||
expect(describeDirectoryError(own)).toMatch(/marked as your own/);
|
||||
expect(describeDirectoryError(own)).not.toContain("x:Account/query");
|
||||
expect(describeDirectoryError({ status: 403, code: "administration_disabled", message: "…" })).toMatch(/turned off/);
|
||||
expect(describeDirectoryError({ method: "x:Account/query", type: "unsupportedFilter", message: "x:Account/query: unsupportedFilter - type" })).toBe("The mail server could not carry out the request (unsupportedFilter).");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,35 +215,83 @@ export function quotasWithDisk(quotas: Record<string, number> | undefined, bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Say what went wrong in terms of the person's own action.
|
||||
*
|
||||
* Stalwart's descriptions are often exact and occasionally all there is -- a
|
||||
* password policy says what it wants, in English -- so a description is kept
|
||||
* where it carries something the type does not.
|
||||
* The server's own wording for a value one of its validators refused, and
|
||||
* what to say instead. These come from the registry's string validators
|
||||
* (`crates/registry/src/types/string.rs`), which is the whole list: anything
|
||||
* else Stalwart says about a value is picked up by the fallback below.
|
||||
*/
|
||||
export function describeDirectoryError(err: unknown): string {
|
||||
const VALIDATOR_MESSAGES: Record<string, () => string> = {
|
||||
"Invalid domain name": () => t("That isn't a valid domain name. Use a name such as example.com, on a real top-level domain."),
|
||||
"Invalid email address": () => t("That isn't a valid email address. Use a full address, such as [email protected]."),
|
||||
"Invalid email local part": () => t("That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @."),
|
||||
"Invalid hostname or IP address": () => t("That isn't a valid host name or IP address."),
|
||||
"String cannot be empty": () => t("A required value was left empty."),
|
||||
};
|
||||
|
||||
/** What kind of thing a refusal was about, where the wording has to differ. */
|
||||
export type DirectoryObject = "account" | "domain";
|
||||
|
||||
/**
|
||||
* Say what went wrong in terms of the person's own action, in their language.
|
||||
*
|
||||
* Stalwart explains a refusal in English, and its words are never shown as
|
||||
* they are: an interface in German that answers in English reads as broken
|
||||
* even when the English is exact. Every type the registry returns has its
|
||||
* own message, and a value a validator refused is recognised by the
|
||||
* validator's wording and said again here.
|
||||
*
|
||||
* One exception, on purpose. A password policy is the server's to set -- a
|
||||
* length, a strength -- and there is no way to know its rule in advance to
|
||||
* translate it, so its reason is kept after a translated sentence. Dropping it
|
||||
* would leave "not accepted" with no way to find out why.
|
||||
*/
|
||||
export function describeDirectoryError(err: unknown, object: DirectoryObject = "account"): string {
|
||||
if (!(err instanceof DirectoryError)) {
|
||||
const e = err as { type?: string; message?: string };
|
||||
const e = err as { type?: string; code?: string; status?: number };
|
||||
// ihasmail's own proxy, refusing for this session or this installation.
|
||||
if (e?.code === "administration_needs_own_device") return t("Only on a device you've marked as your own. Sign in again with “This is my own device” ticked.");
|
||||
if (e?.code === "administration_disabled") return t("Administration is turned off on this installation.");
|
||||
if (e?.code === "network_error" || e?.status === 0) return t("Network error. Please check your connection.");
|
||||
if (e?.code === "rate_limited" || e?.status === 429) return t("Too many attempts. Please wait a few minutes and try again.");
|
||||
// A method-level JMAP error: the whole call was refused.
|
||||
if (e?.type === "forbidden") return t("The mail server refused this. Your role may not allow it.");
|
||||
return e?.message ?? String(err);
|
||||
if (e?.type) return t("The mail server could not carry out the request ({code}).", { code: e.type });
|
||||
return t("The mail server could not carry out the request ({code}).", { code: e?.code ?? "error" });
|
||||
}
|
||||
const description = err.description ?? "";
|
||||
switch (err.type) {
|
||||
case "forbidden":
|
||||
return err.description ? t("The mail server refused this: {reason}", { reason: err.description }) : t("The mail server refused this. Your role may not allow it.");
|
||||
if (/not authorized to grant/i.test(description)) return t("You can't give an account permissions your own role doesn't have.");
|
||||
if (/external directory/i.test(description)) return t("This account signs in through an external directory, so its password can't be set here.");
|
||||
if (/licen[cs]ed account limit/i.test(description)) return t("The server's licence allows no more accounts.");
|
||||
return t("The mail server refused this. Your role may not allow it.");
|
||||
case "primaryKeyViolation":
|
||||
return t("That address is already in use on this server, as an account, a list or an alias.");
|
||||
return object === "domain"
|
||||
? t("That domain name is already in use on this server, as a domain or another domain's other name.")
|
||||
: t("That address is already in use on this server, as an account, a list or an alias.");
|
||||
case "invalidForeignKey":
|
||||
return t("One of the chosen domain, role or group can't be used for this account.");
|
||||
case "overQuota":
|
||||
return t("Your organisation has reached the number of accounts it is allowed.");
|
||||
return object === "domain" ? t("Your organisation has reached the number of domains 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":
|
||||
return t("This account no longer exists. Someone may have deleted it.");
|
||||
return object === "domain" ? t("This domain no longer exists. Someone may have removed 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":
|
||||
return t("That is more than the mail server accepts in one change.");
|
||||
case "invalidPatch":
|
||||
case "invalidProperties":
|
||||
if (err.properties.includes("secret")) return err.description ? t("The password was not accepted: {reason}", { reason: err.description }) : t("The password was not accepted.");
|
||||
return err.description ? t("The mail server rejected a value: {reason}", { reason: err.description }) : t("The mail server rejected a value.");
|
||||
case "validationFailed": {
|
||||
if (err.properties.includes("secret")) {
|
||||
return description ? t("The password was not accepted: {reason}", { reason: description }) : t("The password was not accepted.");
|
||||
}
|
||||
const known = VALIDATOR_MESSAGES[description];
|
||||
if (known) return known();
|
||||
return t("The mail server rejected one of the values. Check what you entered and try again.");
|
||||
}
|
||||
default:
|
||||
return err.description ?? err.type;
|
||||
return t("The mail server refused the change ({code}).", { code: err.type });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user