Use American English spelling throughout

This commit is contained in:
2026-09-15 11:45:58 -07:00
parent 6ba89696ee
commit d1731efdb9
169 changed files with 786 additions and 795 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ test("with 2FA on, a password change needs the current code too", async () => {
test("2FA is switched off with the password and a current code", async () => {
const state = await call("/api/account/security");
assert.equal(state.body.otpEnabled, true);
// The enrolment secret is known only to the client, so disabling uses a code
// The enrollment secret is known only to the client, so disabling uses a code
// from the authenticator - here, the one the mock stored.
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
const params = parseOtpauthUrl(stored!);
+4 -4
View File
@@ -169,10 +169,10 @@ export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
}
/**
* Start enrolment: mint a secret and hand back the URL to show as a QR code.
* Start enrollment: mint a secret and hand back the URL to show as a QR code.
* Nothing is stored until the user proves they can produce a code from it.
*/
export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
export function beginOtpEnrollment(ctx: Ctx): { secret: string; url: string } {
const secret = generateSecret();
return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) };
}
@@ -184,7 +184,7 @@ export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
* the new secret, so without this an authenticator that was mistyped or out of
* step would lock the user out of their mailbox at the next sign-in.
*/
export function assertEnrolmentCode(url: string, code: string): void {
export function assertEnrollmentCode(url: string, code: string): void {
const params = parseOtpauthUrl(url);
if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url");
if (!verifyTotp(params, code)) {
@@ -193,7 +193,7 @@ export function assertEnrolmentCode(url: string, code: string): void {
}
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
assertEnrolmentCode(opts.url, opts.code);
assertEnrollmentCode(opts.url, opts.code);
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
+3 -3
View File
@@ -73,14 +73,14 @@ test("no capabilities at all is treated the same way", async () => {
const STALWART = "urn:stalwart:jmap";
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
test("a 0.16 server is recognised from primaryAccounts, where it advertises itself", () => {
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
assert.equal(
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
true,
);
});
test("a 0.16 server is recognised from an account's capabilities", () => {
test("a 0.16 server is recognized from an account's capabilities", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
@@ -100,7 +100,7 @@ test("a server that advertises it nowhere is one we do not support", () => {
assert.equal(hasStalwartRegistry(undefined), false);
});
test("a shared account carrying the capability is enough to recognise the server", () => {
test("a shared account carrying the capability is enough to recognize the server", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
+2 -2
View File
@@ -13,7 +13,7 @@
* An allowlist rather than a list of administrative objects, because the
* registry has dozens of them -- listeners, stores, tracers, system settings --
* and a new release adds more. An object not named here is refused, which errs
* towards the operator's decision.
* toward the operator's decision.
*
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
* touched: they act on what the account can already reach.
@@ -65,7 +65,7 @@ export function mayNameRegistryMethod(raw: string): boolean {
/**
* Check a JMAP request body. On success, hands back the body to forward --
* serialised from what was inspected, so the server can never be sent
* serialized from what was inspected, so the server can never be sent
* something different from what was checked (a duplicate key, say, read one
* way here and another way there).
*/
+7 -7
View File
@@ -29,8 +29,8 @@ import {
} from "./upstream.js";
import {
AccountError,
assertEnrolmentCode,
beginOtpEnrolment,
assertEnrollmentCode,
beginOtpEnrollment,
changePassword,
createAppPassword,
disableOtp,
@@ -404,7 +404,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
);
}
/*
* A 401 is a judgement about the password and stays counted. Anything
* A 401 is a judgment about the password and stays counted. Anything
* else -- refused, timed out, DNS, TLS -- is the upstream failing to
* answer, which says nothing about the credentials and must not spend
* somebody's attempts while they wait for it to come back (#239).
@@ -559,7 +559,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
api.post("/account/2fa/begin", requireSession, async (c) => {
try {
// Nothing is stored yet; the client hands the URL back to confirm.
return c.json(beginOtpEnrolment(await accountCtx(c)));
return c.json(beginOtpEnrollment(await accountCtx(c)));
} catch (err) {
return accountFailure(c, err);
}
@@ -584,7 +584,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
* the moment 2FA is enabled this session can no longer authenticate at all.
*/
try {
assertEnrolmentCode(body.url, code);
assertEnrollmentCode(body.url, code);
} catch (err) {
return accountFailure(c, err);
}
@@ -691,7 +691,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
// ---------- Administration: Stalwart's permission list ----------
/*
* The one administration read that is not a JMAP call: the labelled list of
* The one administration read that is not a JMAP call: the labeled list of
* permissions from Stalwart's schema, for the Roles picker. Behind the same
* two gates as the registry methods, so a session that may not administer
* learns nothing from it.
@@ -950,7 +950,7 @@ const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "con
* small IncomingMessage/ServerResponse pair.
*
* Returns a Response Hono treats as already sent: the raw bindings are
* written to directly, and the returned value is never serialised.
* written to directly, and the returned value is never serialized.
*/
const SSE_HEADERS = {
"content-type": "text/event-stream",
+1 -1
View File
@@ -77,7 +77,7 @@ test("junk in the chain is discarded rather than used as a key", () => {
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "" }, cfg), "127.0.0.1");
});
test("bracketed and IPv4-mapped forms are normalised", () => {
test("bracketed and IPv4-mapped forms are normalized", () => {
assert.equal(resolveClientIp("::1", { forwardedFor: "[2001:db8::5]" }, cfg), "2001:db8::5");
assert.equal(resolveClientIp("::1", { forwardedFor: "::ffff:198.51.100.7" }, cfg), "198.51.100.7");
});
+2 -2
View File
@@ -234,7 +234,7 @@ export function parseStalwartServers(raw: unknown, file: string): { urls: Record
a mapping that silently never matches. */
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalised`);
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalized`);
/* A domain's value is its server's URL, or an object that also names where
that server's own administration is: `{"url": …, "adminUrl": …}`. */
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
@@ -356,7 +356,7 @@ export const config = {
compressJmap: process.env.COMPRESS_JMAP !== "0",
/*
* How push reaches the browser. "relay" holds one upstream stream per tab
* (today's behaviour). "subscribe" registers one JMAP PushSubscription per
* (today's behavior). "subscribe" registers one JMAP PushSubscription per
* account and fans Stalwart's POSTs out to that account's tabs, holding no
* upstream connection at all -- see push.ts. It needs PUSH_URL: the https
* origin Stalwart can reach ihasmail at, with a certificate it trusts.
+1 -1
View File
@@ -45,7 +45,7 @@ test("an unmapped domain still goes to the default while others are mapped", ()
});
test("the domain is matched however it was typed", () => {
// Keys are normalised on load; the username has to be normalised the same
// Keys are normalized on load; the username has to be normalized the same
// way or a mapping silently never matches.
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
try {
+1 -1
View File
@@ -15,7 +15,7 @@ const { createApp } = await import("./app.js");
* it is pointed at.
*/
test("addresses we must never reach are recognised", () => {
test("addresses we must never reach are recognized", () => {
for (const a of [
"127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1",
"169.254.169.254", // cloud metadata, the classic SSRF target
+1 -1
View File
@@ -113,7 +113,7 @@ export function createDirectory(opts: Options) {
{ id: "k2", "@type": "Dkim1RsaSha256", domainId: "d1", selector: "v1-rsa-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
{ id: "k3", "@type": "Dkim1Ed25519Sha256", domainId: "d2", selector: "v1-ed25519-20260710", stage: "active", createdAt: "2026-07-10T09:00:00Z", nextTransitionAt: null, memberTenantId: null },
];
/** What Stalwart's BIND serialiser writes, including a TXT long enough to be split. */
/** What Stalwart's BIND serializer writes, including a TXT long enough to be split. */
const zoneFile = (d: Obj): string => {
const n = String(d.name);
const lines = [
+8 -8
View File
@@ -24,7 +24,7 @@ const PORT = Number(process.env.MOCK_PORT ?? 8788);
*/
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
/**
* Stalwart advertises FUTURERELEASE in the session but only honours it when
* Stalwart advertises FUTURERELEASE in the session but only honors it when
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
* off, in which case the hold is dropped without a word and the message goes
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
@@ -200,7 +200,7 @@ function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string,
* A marketing template of the shape #290 was reported against.
*
* Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a
* `<style>` block, a coloured call to action, a grey footer — and that is the
* `<style>` block, a colored call to action, a gray footer — and that is the
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
* mock without one could not show what "apply the theme to messages too" does
* to the mail people actually receive: nothing at all.
@@ -706,7 +706,7 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
* answer. One event still comes back as a bare object, the shape this returned
* when an invitation was all it had to handle.
*
* The synthetic organiser and attendee only go on events that arrived with a
* The synthetic organizer and attendee only go on events that arrived with a
* METHOD. Those are scheduling messages, which is what the invitation fixtures
* are; a plain export is not addressed to anyone, and inventing participants
* for it would make imported events look like invitations nobody sent.
@@ -920,7 +920,7 @@ const handlers: Record<string, Handler> = {
"Email/query": (a) => {
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
/*
* Honour the sort rather than always answering newest-first. This used to
* Honor the sort rather than always answering newest-first. This used to
* ignore it entirely, which reproduced a server that silently returns a
* different order from the one asked for -- the one shape of wrongness a
* client cannot detect.
@@ -1040,7 +1040,7 @@ const handlers: Record<string, Handler> = {
return setResp({ updated: { singleton: null } });
},
/*
* Push subscriptions. The JMAP half can be modelled; delivery cannot -- that
* Push subscriptions. The JMAP half can be modeled; delivery cannot -- that
* runs through the browser vendor's real push service, so nothing local will
* ever make a notification appear.
*
@@ -1225,7 +1225,7 @@ const handlers: Record<string, Handler> = {
}
const status = undoStatusOf(sub, Date.now());
if (status !== "pending") {
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already cancelled." : "The message has already been sent." };
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already canceled." : "The message has already been sent." };
continue;
}
sub.undoStatus = "canceled";
@@ -1370,7 +1370,7 @@ function checkAuth(req: IncomingMessage): boolean {
const u = raw.slice(0, sep);
const p = raw.slice(sep + 1);
if (u !== USER) return false;
// App passwords are recognised by shape and skip the second factor, which is
// App passwords are recognized by shape and skip the second factor, which is
// exactly what lets a webmail session survive 2FA being switched on.
if (account.appPasswords.some((a) => a.secret === p)) return true;
if (!account.otpUrl) return p === account.password;
@@ -1501,7 +1501,7 @@ export const server = createServer(async (req, res) => {
* **Confirmed live on 0.16.21 (2026-09-06):** the interval is in **seconds**
* — `data: {"interval": 30}` — where up to 0.16.20 the same field carried
* milliseconds. The server floors it at 30 s (asking for 1, 2 or 5 all
* answered 30 and pinged every 30 s) and honours anything above (45 pinged
* answered 30 and pinged every 30 s) and honors anything above (45 pinged
* at 45 s and said 45, 60 at 60 and said 60). `ping=0` disables pings
* altogether; a value that is not a number at all — `abc`, or empty — is a
* 400 before the stream opens.
+1 -1
View File
@@ -38,7 +38,7 @@ describe("expandOccurrences", () => {
assert.equal(out[0]!.index, 0);
});
it("honours count", () => {
it("honors count", () => {
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
assert.equal(expandOccurrences(ev, a, b).length, 3);
+1 -1
View File
@@ -171,7 +171,7 @@ const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRul
* The object a `CalendarEvent/get` returns for one occurrence.
*
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
* master — so an occurrence is recognisable by its `recurrenceId` and by
* master — so an occurrence is recognizable by its `recurrenceId` and by
* nothing else, which is the shape `isRecurring` was written against.
*/
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
+1 -1
View File
@@ -4,7 +4,7 @@
* These are not hand-written. Each was produced by `openssl smime -sign` with a
* generated certificate and is stored base64 so no editor, formatter or
* checkout setting can touch a byte of it -- a signature is over exact octets,
* and a stray line-ending normalisation would turn a working fixture into a
* and a stray line-ending normalization would turn a working fixture into a
* broken one for reasons invisible in a diff.
*
* The same files back the unit tests, in web/src/lib/smime/__tests__/fixtures.
+1 -1
View File
@@ -12,7 +12,7 @@ test("the account's permissions are kept alongside the edition", () => {
});
test("permission names read the same whichever case the server uses", () => {
// The source serialises camelCase; the documentation shows kebab-case.
// The source serializes camelCase; the documentation shows kebab-case.
assert.equal(normalizePermission("sys-account-get"), "sysAccountGet");
assert.equal(normalizePermission("sysAccountGet"), "sysAccountGet");
assert.equal(normalizePermission("sys-dkim-signature-create"), "sysDkimSignatureCreate");
+1 -1
View File
@@ -75,7 +75,7 @@ export interface CreateSessionParams {
* other sessions" button: `app.ts` also calls it when the password or the app
* password changes, so it carries the guarantee that changing a credential
* invalidates the sessions still holding the old one. A stateless backend
* cannot honour that alone; the plan is for OAuth to hand the job to
* cannot honor that alone; the plan is for OAuth to hand the job to
* Stalwart's own token registry, which can already answer both questions.
*/
export interface SessionBackend {
+1 -1
View File
@@ -72,7 +72,7 @@ test("every entry in the example mapping is a domain and an http(s) URL", () =>
if (key.startsWith("_")) continue;
const domain = key.trim().toLowerCase().replace(/\.$/, "");
assert.ok(domain, "a domain key is empty");
assert.ok(!seen.has(domain), `${domain} appears twice once normalised`);
assert.ok(!seen.has(domain), `${domain} appears twice once normalized`);
seen.add(domain);
// A URL, or an object naming the server's URL and its administration's.
const entry = value && typeof value === "object" ? (value as Record<string, unknown>) : { url: value };
+2 -2
View File
@@ -88,7 +88,7 @@ export function staticHandler(root: string, basePath = ""): Handler {
* already being read here, so checking what it asks for costs one substring
* search per rebuild and turns a mystery into a line in the log.
*
* A warning rather than a refusal: this reads a built artefact to guess at a
* A warning rather than a refusal: this reads a built artifact to guess at a
* misconfiguration, and a wrong guess that stops the server from starting is
* worse than the problem it is describing.
*/
@@ -128,7 +128,7 @@ export function staticHandler(root: string, basePath = ""): Handler {
* comes off once, here. Anything outside it is a 404 and not the app
* shell: under `/mail` this process shares a hostname with whatever else
* the proxy serves, and answering `/` or `/other-app/thing` with our
* index would shadow a neighbour rather than let it 404 honestly.
* index would shadow a neighbor rather than let it 404 honestly.
*/
const fullPath = decodeURIComponent(new URL(c.req.url).pathname);
const urlPath = stripBasePath(basePath, fullPath);
+2 -2
View File
@@ -1,13 +1,13 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* TOTP (RFC 6238) — just enough to enrol a second factor safely.
* TOTP (RFC 6238) — just enough to enroll a second factor safely.
*
* Stalwart stores the otpauth:// URL and checks codes at login, but it does
* *not* check the new secret when 2FA is switched on: it verifies the
* credentials that are already on the account. A user whose authenticator was
* mistyped or whose clock has drifted would be locked out of their mailbox at
* the next sign-in. So ihasmail proves the enrolment itself, before asking the
* the next sign-in. So ihasmail proves the enrollment itself, before asking the
* server to store anything.
*/
+4 -4
View File
@@ -250,7 +250,7 @@ const SCRIPT_MODIFIERS: Record<string, string> = {
};
/**
* Normalise a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
* Normalize a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
* ("de-DE"). Returns null for the locale-less values ("C", "POSIX") and for
* anything that does not look like a language tag.
*/
@@ -336,10 +336,10 @@ function localeOf(call: [string, Record<string, unknown>, string] | undefined):
}
/**
* Permission names in the form the source serialises them.
* Permission names in the form the source serializes them.
*
* Stalwart 0.16 builds `/api/account`'s list from the same enum as everything
* else, which serialises as camelCase (`sysAccountGet`). Its documentation and
* else, which serializes as camelCase (`sysAccountGet`). Its documentation and
* OpenAPI example show kebab-case (`sys-account-get`) instead. Until a live
* server settles which is true, both are read as the one form, so a check
* written against `sysAccountGet` holds either way.
@@ -426,7 +426,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
* So by default only the path and query are taken from the advertised URL;
* scheme, host and port come from the configured base. That is what a proxy
* should have done all along -- the operator named the route on purpose.
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behaviour for a setup
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behavior for a setup
* that genuinely needs to reach Stalwart at a different origin than the one
* it was given.
*/