Use American English spelling throughout
This commit is contained in:
@@ -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!);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user