Merge PR #829: select and preserve the mail database port safely

This commit is contained in:
Hydra
2026-09-15 21:58:24 +03:00
10 changed files with 844 additions and 39 deletions
@@ -241,4 +241,25 @@ describeDockerE2E("mail engine DB bootstrap (real postgres)", () => {
const r = await docker(["exec", RUNNER, "bash", "-c", "exit 3"], { allowFail: true });
expect(r.code).toBe(3);
}, 60_000);
it("bootstraps a database on a configured non-default SQL port", async () => {
const custom = `${DB}-custom-port`;
try {
await docker([
"run", "-d", "--name", custom, "--network", NET,
"--env", `POSTGRES_PASSWORD=${PG_PASSWORD}`, "--env", "POSTGRES_DB=vmail",
DB_IMAGE, "postgres", "-p", "5544",
]);
const result = await runBootstrap({ OPENSHIP_MAIL_DB_HOST: custom, OPENSHIP_MAIL_DB_PORT: "5544" });
expect(result.code, result.log).toBe(0);
const state = await docker([
"exec", "--env", `PGPASSWORD=${PG_PASSWORD}`, RUNNER,
"psql", "-h", custom, "-p", "5544", "-U", "postgres", "-d", "vmail", "-tAc",
"SELECT current_setting('port') || ':' || count(*) FROM mailbox WHERE password <> ''",
]);
expect(state.stdout.trim()).toBe("5544:1");
} finally {
await docker(["rm", "-fv", custom], { allowFail: true });
}
}, 120_000);
});
@@ -0,0 +1,143 @@
import { afterEach, describe, expect, it } from "vitest";
import { execFileSync } from "node:child_process";
import {
chmod,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
const email = resolve(import.meta.dirname, "../../../email");
const script = join(email, "docker/reconcile-db-port.py");
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function root() {
const directory = await mkdtemp(join(tmpdir(), "openship-mail-port-test-"));
roots.push(directory);
return directory;
}
async function file(root: string, path: string, content: string) {
const destination = join(root, path);
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, content, { mode: 0o640 });
return destination;
}
const run = (root: string, port: string) =>
execFileSync("python3", [script, port, root], { encoding: "utf8", stdio: "pipe" });
const settings =
["vmail", "amavisd", "iredapd", "iredadmin"]
.map(
(prefix) =>
`${prefix}_db_server = "127.0.0.1"\n${prefix}_db_port = "5432"\n${prefix}_db_password = "literal 5432 port=995"`,
)
.join("\n") + "\nlisten_port = 7777\n";
async function fixture() {
const directory = await root();
const paths: string[] = [];
const sample = async (name: string, destination: string) => {
const text = (await readFile(join(email, "engine/samples", name), "utf8"))
.replaceAll("PH_SQL_SERVER_ADDRESS", "127.0.0.1")
.replaceAll("PH_SQL_SERVER_PORT", "5432")
.replaceAll("PH_AMAVISD_PERL_SQL_DBI", "Pg");
paths.push(await file(directory, destination, text));
};
for (const name of await readdir(join(email, "engine/samples/postfix/pgsql"))) {
await sample(`postfix/pgsql/${name}`, `etc/postfix/pgsql/${name}`);
}
for (const name of [
"dovecot-sql.conf",
"dovecot-used-quota.conf",
"dovecot-last-login.conf",
"dovecot-share-folder.conf",
]) {
await sample(`dovecot/${name}`, `etc/dovecot/${name}`);
}
await sample("amavisd/amavisd.conf", "etc/amavis/conf.d/50-user");
paths.push(await file(directory, "opt/iredapd/settings.py", settings));
return { directory, paths };
}
describe("mail database port reconciliation", () => {
it("updates actual Postfix, Dovecot, Amavis and iRedAPD configs and can return to the default", async () => {
const { directory, paths } = await fixture();
const originals = await Promise.all(paths.map((path) => readFile(path, "utf8")));
for (const port of ["5433", "5435", "5432"]) {
run(directory, port);
for (const [index, path] of paths.entries()) {
const expected = originals[index]!.replaceAll("5432", port).replaceAll(
`literal ${port} port=995`,
"literal 5432 port=995",
);
expect(await readFile(path, "utf8"), path).toBe(expected);
expect((await stat(path)).mode & 0o777).toBe(0o640);
}
const before = await Promise.all(paths.map((path) => stat(path)));
run(directory, port);
expect(
(await Promise.all(paths.map((path) => stat(path)))).map((s) => [s.ino, s.mtimeMs]),
).toEqual(before.map((s) => [s.ino, s.mtimeMs]));
}
});
it("preserves listeners, SQL credentials, foreign database endpoints, and fail2ban actions", async () => {
const directory = await root();
const untouched = {
"etc/postfix/main.cf": "relayhost = [relay.example]:5432\n",
"etc/postfix/pgsql/remote.cf": "hosts = db.example:5432\npassword = 5432\n",
"etc/dovecot/listeners.conf":
"service imap-login {\n inet_listener imap {\n port = 143\n }\n}\n",
"etc/dovecot/remote.conf":
"connect = host=db.example port=5432 dbname=vmail password=' port=5432'\n",
"etc/amavis/conf.d/60-listeners": "$inet_socket_port = [10024,10026];\n$password = '5432';\n",
"etc/fail2ban/jail.local": 'action = banned_db[name=sshd, port="5432", protocol=tcp]\n',
"opt/iredapd/settings.py":
'vmail_db_server = "db.example"\nvmail_db_port = "5432"\nlisten_port = 7777\n',
};
for (const [path, content] of Object.entries(untouched)) await file(directory, path, content);
run(directory, "5433");
for (const [path, content] of Object.entries(untouched))
expect(await readFile(join(directory, path), "utf8")).toBe(content);
});
it.each(["0", "65536", "5432.5", "5e3", "invalid", "5432;exit 0"])(
"refuses invalid port %s before touching configuration",
async (port) => {
const directory = await root();
const path = await file(directory, "opt/iredapd/settings.py", settings);
expect(() => run(directory, port)).toThrow(/decimal port between 1 and 65535/);
expect(await readFile(path, "utf8")).toBe(settings);
},
);
it("removes stale settings bytecode while preserving permissions and skipping config symlinks", async () => {
const { directory } = await fixture();
const settingsPath = join(directory, "opt/iredapd/settings.py");
await chmod(settingsPath, 0o600);
const before = await stat(settingsPath);
const bytecode = await file(
directory,
"opt/iredapd/__pycache__/settings.cpython-311.pyc",
"old-bytecode",
);
const outside = await file(await root(), "outside.conf", "hosts = 127.0.0.1:5432\n");
await symlink(outside, join(directory, "etc/postfix/pgsql/symlink.cf"));
run(directory, "5433");
expect((await stat(settingsPath)).mode & 0o777).toBe(0o600);
expect((await stat(settingsPath)).uid).toBe(before.uid);
expect((await stat(settingsPath)).gid).toBe(before.gid);
expect(await readFile(outside, "utf8")).toBe("hosts = 127.0.0.1:5432\n");
await expect(stat(bytecode)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readdir(join(directory, "opt/iredapd"))).toEqual(["__pycache__", "settings.py"]);
});
});
+12
View File
@@ -46,6 +46,18 @@ themselves separately.
## Database topology - one host, four DBs
Container installs publish the mail database only on host loopback. New setups try
port 5432, then the first free port in 5433–5460 if 5432 is occupied. To select a
specific port, set `OPENSHIP_MAIL_DB_PORT` in the API process environment before
running mail setup. The value must be a decimal port from 1 to 65535; an occupied
explicit port stops setup with an error.
The selected port is saved with the mail engine configuration and reused when
recreating a missing engine. Existing installations keep their retained port;
changing the API setting does not move a running database. Start-only repairs read
the existing container binding. Inside the PostgreSQL container the port remains
5432, and the mail daemons use the selected host port.
```
openship Postgres ($DATABASE_URL) ← unrelated to mail
└── schema "public"
+7 -3
View File
@@ -35,9 +35,8 @@ set -euo pipefail
log() { echo "[openship-mail] $*"; }
# No DB_HOST/DB_PORT here on purpose: db-bootstrap.sh reads the same two env vars and
# owns every conversation with the sidecar, so duplicating them invites the two files
# to disagree about where the database is.
# db-bootstrap.sh reads OPENSHIP_MAIL_DB_HOST and OPENSHIP_MAIL_DB_PORT to wait for
# and bootstrap the schema; step 3d reconciles that same port into daemon configs.
FIRST_DOMAIN="${FIRST_DOMAIN:-}"
SEED_DIR="/opt/openship-mail/seed"
@@ -147,6 +146,11 @@ fi
# on every boot so recreating the container retains the mounted TLS identity.
bash /opt/openship-mail/reconcile-ssl.sh "$FIRST_DOMAIN"
# 3d. Keep daemon SQL connections on the sidecar's selected host port. Only
# database connection fields are rewritten; mail listener ports stay intact.
python3 /opt/openship-mail/reconcile-db-port.py "${OPENSHIP_MAIL_DB_PORT:-5432}"
# 4. bootstrap the mail databases (idempotent; skips if the vmail schema exists).
#
# The wait for the sidecar lives INSIDE db-bootstrap.sh, which polls `SELECT 1` until
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Reconcile SQL connection fields in the Debian mail image's daemon configs."""
import os
from pathlib import Path
import re
import stat
import sys
import tempfile
def replace_port(text, pattern, port):
return re.sub(pattern, lambda match: match[1] + port, text, flags=re.MULTILINE)
def postfix(text, port):
return replace_port(
text, r"^([ \t]*hosts[ \t]*=[ \t]*(?:127\.0\.0\.1|localhost):)[0-9]+\b", port
)
def dovecot(text, port):
# Dovecot 2.3 (Debian 12) libpq connection strings. Match the connection
# prefix, not arbitrary 'port=' text in passwords or listener blocks.
return replace_port(
text,
r"^([ \t]*connect[ \t]*=[ \t]*host=(?:127\.0\.0\.1|localhost)[ \t]+port=)[0-9]+\b",
port,
)
def amavis(text, port):
return replace_port(
text,
r"^([ \t]*@(?:storage|lookup)_sql_dsn[ \t]*=[ \t]*\([ \t]*\[[ \t]*['\"]DBI:Pg:database=[^;'\"\r\n]+;host=(?:127\.0\.0\.1|localhost);port=)[0-9]+\b",
port,
)
def iredapd(text, port):
local = set(re.findall(
r"^[ \t]*(vmail|amavisd|iredapd|iredadmin)_db_server[ \t]*=[ \t]*['\"](?:127\.0\.0\.1|localhost)['\"]",
text, flags=re.MULTILINE,
))
return re.sub(
r"^([ \t]*(vmail|amavisd|iredapd|iredadmin)_db_port[ \t]*=[ \t]*)(['\"]?)[0-9]+\3(?=[ \t]*(?:#.*)?$)",
lambda match: match[1] + match[3] + port + match[3] if match[2] in local else match[0],
text, flags=re.MULTILINE,
)
def reconcile(path, transform, port, root):
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root):
return False
before = path.read_text()
after = transform(before, port)
if before == after:
return False
metadata = path.stat()
fd, temporary = tempfile.mkstemp(prefix=".openship-db-port-", dir=path.parent)
try:
with os.fdopen(fd, "w") as output:
owned = os.fstat(output.fileno())
if (owned.st_uid, owned.st_gid) != (metadata.st_uid, metadata.st_gid):
os.fchown(output.fileno(), metadata.st_uid, metadata.st_gid)
os.fchmod(output.fileno(), stat.S_IMODE(metadata.st_mode))
output.write(after)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return True
def main():
raw = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("OPENSHIP_MAIL_DB_PORT", "5432")
if not re.fullmatch(r"[0-9]+", raw.strip()) or not 1 <= int(raw) <= 65535:
raise ValueError("OPENSHIP_MAIL_DB_PORT must be a decimal port between 1 and 65535")
port = str(int(raw))
# The optional root lets tests run the same script against actual iRedMail
# sample configs without touching the developer's system configuration.
root = Path(sys.argv[2] if len(sys.argv) > 2 else "/").resolve()
for path in (root / "etc/postfix/pgsql").glob("*.cf"):
reconcile(path, postfix, port, root)
for path in (root / "etc/dovecot").rglob("*.conf"):
reconcile(path, dovecot, port, root)
for path in (root / "etc/amavis/conf.d").glob("*"):
reconcile(path, amavis, port, root)
settings = root / "opt/iredapd/settings.py"
if reconcile(settings, iredapd, port, root):
# Python's timestamp cache can otherwise survive a same-second change
# between two ports with equal length. Never rewrite compiled bytecode.
for cached in [settings.with_suffix(".pyc"), *(settings.parent / "__pycache__").glob("settings.*.pyc")]:
if cached.resolve().is_relative_to(root):
cached.unlink(missing_ok=True)
print(f"[openship-mail] reconciled daemon SQL connections to port {port}")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError) as error:
print(f"[openship-mail] FATAL: database port reconciliation failed: {error}", file=sys.stderr)
sys.exit(1)
+8
View File
@@ -214,6 +214,11 @@ export {
MAIL_DB_USER,
MAIL_DB_HOST_BIND,
MAIL_DB_PORT,
MAIL_DB_DEFAULT_PORT,
MAIL_DB_FALLBACK_PORT,
MAIL_DB_PORT_RANGE_MAX,
MAIL_DB_INTERNAL_PORT,
resolveMailDbPort,
type MailMount,
} from "./infra/mail-container";
@@ -284,6 +289,9 @@ export {
detectMailContainer,
verifyMailEngine,
buildMailRunCommand,
buildDbRunCommand,
retainedDbPort,
findAvailableMailDbPort,
MAIL_DB_IMAGE,
type ContainerMailOptions,
type ContainerMailResult,
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it } from "vitest";
import {
MAIL_DB_DEFAULT_PORT,
MAIL_DB_FALLBACK_PORT,
MAIL_DB_PORT_RANGE_MAX,
MAIL_DB_INTERNAL_PORT,
resolveMailDbPort,
} from "./mail-container";
const originalEnv = process.env.OPENSHIP_MAIL_DB_PORT;
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.OPENSHIP_MAIL_DB_PORT;
} else {
process.env.OPENSHIP_MAIL_DB_PORT = originalEnv;
}
});
describe("resolveMailDbPort", () => {
it("defaults to 5432 when no argument or env var is present", () => {
delete process.env.OPENSHIP_MAIL_DB_PORT;
expect(resolveMailDbPort()).toBe(5432);
expect(resolveMailDbPort(undefined)).toBe(5432);
expect(resolveMailDbPort("")).toBe(5432);
});
it("reads from OPENSHIP_MAIL_DB_PORT environment variable", () => {
process.env.OPENSHIP_MAIL_DB_PORT = "5433";
expect(resolveMailDbPort()).toBe(5433);
});
it("parses valid port numbers and strings", () => {
expect(resolveMailDbPort("5433")).toBe(5433);
expect(resolveMailDbPort(" 5434 ")).toBe(5434);
expect(resolveMailDbPort(5435)).toBe(5435);
expect(resolveMailDbPort(1)).toBe(1);
expect(resolveMailDbPort(65535)).toBe(65535);
});
it.each(["0", "-1", "65536", "not-a-port", "5432.5", "0x1538", "5e3", " "])(
"refuses an invalid explicit port %s",
(value) => expect(() => resolveMailDbPort(value)).toThrow(/between 1 and 65535/),
);
it("exports matching internal and default port constants", () => {
expect(MAIL_DB_DEFAULT_PORT).toBe(5432);
expect(MAIL_DB_INTERNAL_PORT).toBe(5432);
expect(MAIL_DB_FALLBACK_PORT).toBe(5433);
expect(MAIL_DB_PORT_RANGE_MAX).toBe(5460);
});
});
+25 -2
View File
@@ -91,9 +91,32 @@ export const MAIL_DB_CONTAINER_DATA_DIR = "/var/lib/postgresql/data";
export const MAIL_DB_PGDATA = `${MAIL_DB_CONTAINER_DATA_DIR}/pgdata`;
export const MAIL_DB_NAME = "vmail";
export const MAIL_DB_USER = "vmail";
/** Loopback only — the host-networked engine reaches it at 127.0.0.1:5432. */
/** Loopback only — the host-networked engine reaches it at 127.0.0.1. */
export const MAIL_DB_HOST_BIND = "127.0.0.1";
export const MAIL_DB_PORT = 5432;
export const MAIL_DB_DEFAULT_PORT = 5432;
export const MAIL_DB_FALLBACK_PORT = 5433;
export const MAIL_DB_PORT_RANGE_MAX = 5460;
export const MAIL_DB_INTERNAL_PORT = 5432;
/**
* Resolve the host port the mail database listens on.
* Reads `OPENSHIP_MAIL_DB_PORT` at setup time. An invalid explicit setting must
* fail instead of silently connecting the mail engine to a different database.
*/
export function resolveMailDbPort(
raw: string | number | undefined = process.env.OPENSHIP_MAIL_DB_PORT,
): number {
if (raw === undefined || raw === "") return MAIL_DB_DEFAULT_PORT;
const value = String(raw).trim();
const n = Number(value);
if (!/^[0-9]+$/.test(value) || !Number.isInteger(n) || n < 1 || n > 65535) {
throw new Error("OPENSHIP_MAIL_DB_PORT must be a decimal port between 1 and 65535.");
}
return n;
}
/** Legacy default constant; runtime host bindings use resolveMailDbPort(). */
export const MAIL_DB_PORT = MAIL_DB_DEFAULT_PORT;
/**
* Host-side paths for the files the admin layer writes with `exec.writeFile`
@@ -2,12 +2,17 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMailRunCommand,
buildDbRunCommand,
findAvailableMailDbPort,
retainedDbPort,
startContainerMail,
ensureContainerMail,
resolveMailImage,
setDefaultMailImage,
} from "./ensure-container-mail";
import { setManagedImagesFromSource } from "../managed-image";
import { MAIL_HOST_STATE_DIR } from "../../infra/mail-container";
import type { CommandExecutor } from "../../types";
afterEach(() => {
setDefaultMailImage(undefined);
@@ -39,6 +44,132 @@ describe("buildMailRunCommand", () => {
});
});
describe("buildDbRunCommand", () => {
it("defaults to binding host loopback port 5432 to container port 5432", () => {
const cmd = buildDbRunCommand("openship-mail-db");
expect(cmd).toContain("-p '127.0.0.1:5432:5432'");
expect(cmd).toContain("--name 'openship-mail-db'");
expect(cmd).toContain("--restart unless-stopped");
});
it("binds a custom host port mapped to internal 5432 container port", () => {
const cmd = buildDbRunCommand("openship-mail-db", 5433);
expect(cmd).toContain("-p '127.0.0.1:5433:5432'");
});
});
describe("findAvailableMailDbPort", () => {
it("returns preferred port when it is not listening", async () => {
const exec = vi.fn(async () => "");
const port = await findAvailableMailDbPort({ exec } as never, 5432, false, () => {});
expect(port).toBe(5432);
});
it("does not auto-switch when the user explicitly configured the port", async () => {
// Return tcp table showing 5432 listening (0x1538)
const exec = vi.fn(async (cmd: string) => {
if (cmd.includes("/proc/net/tcp"))
return " sl local_address ...\n 0: 00000000:1538 00000000:0000 0A";
return "";
});
await expect(findAvailableMailDbPort({ exec } as never, 5432, true, () => {})).rejects.toThrow(
/already in use/,
);
expect(exec.mock.calls.every(([cmd]) => !cmd.includes("docker"))).toBe(true);
});
it("auto-discovers next available port (e.g. 5433) when default 5432 is occupied", async () => {
const logs: string[] = [];
const exec = vi.fn(async (cmd: string) => {
// 5432 (0x1538) is listening, 5433 (0x1539) is free
if (cmd.includes("/proc/net/tcp"))
return " sl local_address ...\n 0: 00000000:1538 00000000:0000 0A";
return "";
});
const port = await findAvailableMailDbPort({ exec } as never, 5432, false, (l) =>
logs.push(l.message),
);
expect(port).toBe(5433);
expect(logs.some((m) => m.includes("Automatically selected available port 5433"))).toBe(true);
expect(exec.mock.calls.every(([cmd]) => !cmd.includes("docker"))).toBe(true);
});
it("skips multiple occupied ports until a free one is found", async () => {
const exec = vi.fn(async (cmd: string) => {
// 5432 (0x1538) and 5433 (0x1539) are both listening; 5434 (0x153A) is free
if (cmd.includes("/proc/net/tcp")) {
return (
" sl local_address ...\n" +
" 0: 00000000:1538 00000000:0000 0A\n" +
" 1: 00000000:1539 00000000:0000 0A"
);
}
return "";
});
const port = await findAvailableMailDbPort({ exec } as never, 5432, false, () => {});
expect(port).toBe(5434);
});
it("reports exhaustion without removing containers or choosing an occupied port", async () => {
const table = Array.from(
{ length: 29 },
(_, index) => `${index}: 00000000:${(5432 + index).toString(16)} 00000000:0000 0A`,
).join("\n");
const exec = vi.fn(async () => table);
await expect(findAvailableMailDbPort({ exec } as never, 5432, false, () => {})).rejects.toThrow(
/No free mail database port/,
);
});
});
describe("retainedDbPort", () => {
it("returns null when the database cluster is not initialised on disk", async () => {
const exec = vi.fn(async () => "");
const port = await retainedDbPort({ exec } as never);
expect(port).toBeNull();
});
it("reads the retained port from engine.env when cluster is initialised", async () => {
const exec = vi.fn(async (cmd: string) => {
if (cmd.includes("PG_VERSION")) return "yes\n";
return "";
});
const readFile = vi.fn(async () => "OPENSHIP_MAIL_DB_PORT=5435\n");
const port = await retainedDbPort({ exec, readFile } as never);
expect(port).toBe(5435);
});
});
describe("startContainerMail database port", () => {
it("restarts custom-named containers using their binding without reading or rewriting credentials", async () => {
const exec = vi.fn(async (cmd: string) => {
if (cmd.includes(STATE_PROBE)) return stateLine("mail-image:1", false);
if (cmd.includes("HostConfig.PortBindings")) {
expect(cmd).toContain("'custom-mail-db'");
return JSON.stringify({ "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "5435" }] });
}
if (cmd.includes("/proc/net/tcp")) return PROC_LISTENING.replace("1538", "153B");
return "";
});
const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
const readFile = vi.fn(),
writeFile = vi.fn();
const result = await startContainerMail({ exec, streamExec, readFile, writeFile } as never, {
container: "custom-mail",
dbContainer: "custom-mail-db",
onLog: () => {},
});
expect(result).toEqual({ started: true });
expect(streamExec.mock.calls.map((call) => call[0])).toEqual([
"docker start 'custom-mail-db'",
"docker start 'custom-mail'",
]);
expect(readFile).not.toHaveBeenCalled();
expect(writeFile).not.toHaveBeenCalled();
expect(exec.mock.calls.every(([cmd]) => !/docker (rm|run)|PG_VERSION/.test(cmd))).toBe(true);
});
});
describe("resolveMailImage", () => {
it("prefers an explicit ref, else the injected default", () => {
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
@@ -86,7 +217,11 @@ const PROC_LISTENING = [
* `streamExec` and the writes succeed so the bring-up runs end to end.
*/
function firstBootExecutor(opts: { imagePresent: boolean }) {
const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
let dbStarted = false;
const streamExec = vi.fn(async (cmd: string) => {
if (cmd.includes("docker run") && cmd.includes("postgres")) dbStarted = true;
return { code: 0, output: "" };
});
const exec = vi.fn(async (cmd: string) => {
// No engine on the box yet — the state probe finds nothing.
if (cmd.includes(STATE_PROBE)) return "";
@@ -95,7 +230,7 @@ function firstBootExecutor(opts: { imagePresent: boolean }) {
// Image presence probe (docker image inspect -f '{{.Id}}').
if (cmd.includes("docker image inspect")) return opts.imagePresent ? "sha256:abc\n" : "";
// Port-listening probe reads /proc/net/tcp.
if (cmd.includes("/proc/net/tcp")) return PROC_LISTENING;
if (cmd.includes("/proc/net/tcp")) return dbStarted ? PROC_LISTENING : "";
return "";
});
const writeFile = vi.fn(async () => {});
@@ -108,6 +243,35 @@ function firstBootExecutor(opts: { imagePresent: boolean }) {
// skips the pull (an unpublished tag — a pull would 404). In prod the tag is absent,
// so it pulls `:APP_VERSION`.
describe("ensureContainerMail image acquisition gate", () => {
it("reuses an existing sidecar binding when the engine is missing", async () => {
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
const box = firstBootExecutor({ imagePresent: true });
const exec = vi.fn(async (cmd: string) => {
if (cmd.includes("HostConfig.PortBindings"))
return JSON.stringify({ "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "5436" }] });
return (await box.exec(cmd)).replace("1538", "153C");
});
const writeFile = vi.fn(async (_path: string, _content: string) => {});
await ensureContainerMail({ exec, writeFile, streamExec: box.streamExec } as never, {
domain: "example.com",
dbContainer: "custom-mail-db",
secrets: {},
onLog: () => {},
});
expect(
box.streamExec.mock.calls.find(([cmd]) => cmd.includes("postgres:16-alpine"))?.[0],
).toContain("-p '127.0.0.1:5436:5432'");
expect(writeFile.mock.calls.find(([path]) => path.endsWith("engine.env"))?.[1]).toContain(
"OPENSHIP_MAIL_DB_PORT=5436\n",
);
expect(
exec.mock.calls.filter(([cmd]) => cmd.includes("docker rm")).map(([cmd]) => cmd),
).toEqual([
"docker rm -f 'custom-mail-db' 2>/dev/null || true",
"docker rm -f 'openship-mail' 2>/dev/null || true",
]);
});
it("skips the registry pull when the image is already present locally (delivered dev tag)", async () => {
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
const { executor, streamExec } = firstBootExecutor({ imagePresent: true });
@@ -210,12 +374,16 @@ describe("ensureContainerMail swap", () => {
* host (null = the file is gone, the unrecoverable case).
*/
function retainedDbExecutor(opts: { initialised: boolean; retainedEnv: string | null }) {
const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
let dbStarted = false;
const streamExec = vi.fn(async (cmd: string) => {
if (cmd.includes("docker run") && cmd.includes("postgres")) dbStarted = true;
return { code: 0, output: "" };
});
const exec = vi.fn(async (cmd: string) => {
if (cmd.includes(STATE_PROBE)) return "";
if (cmd.includes("docker version")) return "27.0.0\n";
if (cmd.includes("docker image inspect")) return "sha256:abc\n";
if (cmd.includes("/proc/net/tcp")) return PROC_LISTENING;
if (cmd.includes("/proc/net/tcp")) return dbStarted ? PROC_LISTENING : "";
if (cmd.includes("PG_VERSION")) return opts.initialised ? "yes\n" : "";
return "";
});
@@ -228,12 +396,16 @@ function retainedDbExecutor(opts: { initialised: boolean; retainedEnv: string |
}
function envWriteExecutor() {
const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
let dbStarted = false;
const streamExec = vi.fn(async (cmd: string) => {
if (cmd.includes("docker run") && cmd.includes("postgres")) dbStarted = true;
return { code: 0, output: "" };
});
const exec = vi.fn(async (cmd: string) => {
if (cmd.includes(STATE_PROBE)) return "";
if (cmd.includes("docker version")) return "27.0.0\n";
if (cmd.includes("docker image inspect")) return "sha256:abc\n";
if (cmd.includes("/proc/net/tcp")) return PROC_LISTENING;
if (cmd.includes("/proc/net/tcp")) return dbStarted ? PROC_LISTENING : "";
return "";
});
const writeFile = vi.fn(async (_path: string, _content: string) => {});
@@ -249,8 +421,7 @@ describe("engine env-file cannot be injected with extra records", () => {
ensureContainerMail(executor, {
domain: "example.com",
secrets: {
DOMAIN_ADMIN_PASSWD_PLAIN:
"aaaaaaaaaaaa\nBASH_FUNC_psql%%=() { echo pwned; }",
DOMAIN_ADMIN_PASSWD_PLAIN: "aaaaaaaaaaaa\nBASH_FUNC_psql%%=() { echo pwned; }",
},
onLog: () => {},
}),
@@ -261,9 +432,7 @@ describe("engine env-file cannot be injected with extra records", () => {
for (const [, content] of writeFile.mock.calls) {
expect(String(content)).not.toContain("BASH_FUNC");
}
expect(
streamExec.mock.calls.some(([c]) => String(c).includes("docker run")),
).toBe(false);
expect(streamExec.mock.calls.some(([c]) => String(c).includes("docker run"))).toBe(false);
});
it("refuses a carriage return too (CR alone still ends a record)", async () => {
@@ -316,6 +485,28 @@ describe("engine env-file cannot be injected with extra records", () => {
describe("mail database credential over a retained pgdata (GH-564)", () => {
const RETAINED = "POSTGRES_USER=postgres\nPOSTGRES_DB=vmail\nPOSTGRES_PASSWORD=old-cluster-pw\n";
it.each(['"quoted-password"', " leading and trailing "])(
"preserves the literal retained env-file password %s",
async (password) => {
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
const { executor, writeFile } = retainedDbExecutor({
initialised: true,
retainedEnv: `POSTGRES_PASSWORD=${password}\n`,
});
await ensureContainerMail(executor, {
domain: "example.com",
secrets: { PGSQL_ROOT_PASSWD: "new" },
onLog: () => {},
});
expect(writeFile.mock.calls.find(([path]) => path.endsWith("db.env"))?.[1]).toContain(
`POSTGRES_PASSWORD=${password}\n`,
);
expect(writeFile.mock.calls.find(([path]) => path.endsWith("engine.env"))?.[1]).toContain(
`PGSQL_ROOT_PASSWD=${password}\n`,
);
},
);
it("reuses the password the existing cluster was initialised with", async () => {
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
const { executor, writeFile } = retainedDbExecutor({
@@ -356,8 +547,9 @@ describe("mail database credential over a retained pgdata (GH-564)", () => {
}).catch(() => {});
const dbEnv =
writeFile.mock.calls.map(([p, c]) => [String(p), String(c)] as const).find(([p]) => p.endsWith("db.env"))?.[1] ??
"";
writeFile.mock.calls
.map(([p, c]) => [String(p), String(c)] as const)
.find(([p]) => p.endsWith("db.env"))?.[1] ?? "";
expect(dbEnv).toContain("POSTGRES_PASSWORD=newly-generated-pw");
// No cluster on disk, so no reason to read the old file at all.
expect(readFile).not.toHaveBeenCalled();
@@ -489,7 +681,14 @@ function nonRootSudoBox(
}
if (command.includes("docker version")) return "27.0.0\n";
if (command.includes("docker image inspect")) return opts.imagePresent ? "sha256:abc\n" : "";
if (command.includes("/proc/net/tcp")) return PROC_LISTENING;
if (command.includes("/proc/net/tcp")) {
const dbStarted =
Boolean(opts.runningImage) ||
[...streamExec.mock.calls, ...exec.mock.calls].some(([c]) =>
String(c).includes("postgres:16-alpine"),
);
return dbStarted ? PROC_LISTENING : "";
}
// `elevatedExecutor.writeFile` publishes by staging unelevated, then `chown 0:0` + `mv`
// as root — which is where a file becomes root-owned and unreadable to the launcher.
const mv = /mv -f '([^']+)' '([^']+)'/.exec(command);
@@ -518,7 +717,7 @@ function nonRootSudoBox(
});
return {
executor: { exec, streamExec, writeFile } as never,
executor: { exec, streamExec, writeFile } as unknown as CommandExecutor,
exec,
streamExec,
published,
@@ -620,7 +819,9 @@ describe("mail bring-up on a non-root sudo box (GH-630)", () => {
// Search on both components, granted without read so the directory stays unlistable.
expect(box.traversable.has(MAIL_HOST_STATE_DIR)).toBe(true);
expect(box.traversable.has("/var/lib/openship")).toBe(true);
const grants = box.exec.mock.calls.map(([c]) => String(c)).filter((c) => c.includes("chmod a+x"));
const grants = box.exec.mock.calls
.map(([c]) => String(c))
.filter((c) => c.includes("chmod a+x"));
expect(grants.length).toBeGreaterThan(0);
expect(grants.every((c) => !/chmod a\+rx|chmod 0?755/.test(c))).toBe(true);
});
@@ -668,4 +869,108 @@ describe("mail bring-up on a non-root sudo box (GH-630)", () => {
.filter((c) => /chown|chmod a\+x|chmod 400/.test(c));
expect(touched).toEqual([]);
});
it("publishes the configured dbPort for the postgres sidecar and in engine env", async () => {
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
const box = nonRootSudoBox({ imagePresent: true });
const originalExec = box.exec;
box.executor.exec = box.exec = vi.fn(async (cmd: string) => {
if (cmd.includes("/proc/net/tcp")) {
return (
" sl local_address rem_address st ...\n" +
(box
.dockerCommands()
.some((c) => c.includes("docker run") && c.includes("postgres:16-alpine"))
? " 0: 00000000:1539 00000000:0000 0A 00000000:00000000\n"
: "") +
" 1: 00000000:0019 00000000:0000 0A 00000000:00000000\n" +
" 2: 00000000:03E1 00000000:0000 0A 00000000:00000000"
);
}
return originalExec(cmd);
});
await ensureContainerMail(box.executor, {
domain: "example.com",
secrets: { PGSQL_ROOT_PASSWD: "pw" },
dbPort: 5433,
onLog: () => {},
});
const dbRun = box
.dockerCommands()
.find((c) => c.includes("docker run") && c.includes("postgres:16-alpine"));
expect(dbRun).toBeDefined();
expect(dbRun).toContain("-p '127.0.0.1:5433:5432'");
});
it("auto-discovers next available port when default 5432 is occupied during ensureContainerMail", async () => {
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
const box = nonRootSudoBox({ imagePresent: true });
const originalExec = box.exec;
box.executor.exec = box.exec = vi.fn(async (cmd: string) => {
if (cmd.includes("/proc/net/tcp")) {
const dbStarted = box.dockerCommands().some((c) => c.includes("127.0.0.1:5433:5432"));
// Before sidecar start: 5432 is occupied (1538), 5433 is free
// After sidecar start: 5433 is listening (1539)
const portHex = dbStarted ? "1539" : "1538";
return (
" sl local_address rem_address st ...\n" +
` 0: 00000000:${portHex} 00000000:0000 0A 00000000:00000000\n` +
" 1: 00000000:0019 00000000:0000 0A 00000000:00000000\n" +
" 2: 00000000:03E1 00000000:0000 0A 00000000:00000000"
);
}
return originalExec(cmd);
});
await ensureContainerMail(box.executor, {
domain: "example.com",
secrets: { PGSQL_ROOT_PASSWD: "pw" },
onLog: () => {},
});
const dbRun = box
.dockerCommands()
.find((c) => c.includes("docker run") && c.includes("postgres:16-alpine"));
expect(dbRun).toBeDefined();
// Automatically selects 5433
expect(dbRun).toContain("-p '127.0.0.1:5433:5432'");
});
it("retains the previously assigned dbPort from engine.env on an existing cluster", async () => {
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
const box = nonRootSudoBox({ imagePresent: true });
const originalExec = box.exec;
box.executor.exec = box.exec = vi.fn(async (cmd: string) => {
if (cmd.includes("PG_VERSION")) return "yes\n";
if (cmd.includes("cat ") && cmd.includes("engine.env")) return "OPENSHIP_MAIL_DB_PORT=5436\n";
if (cmd.includes("cat ") && cmd.includes("db.env")) return "POSTGRES_PASSWORD=pw\n";
if (cmd.includes("/proc/net/tcp")) {
return (
" sl local_address rem_address st ...\n" +
" 0: 00000000:153C 00000000:0000 0A 00000000:00000000\n" +
" 1: 00000000:0019 00000000:0000 0A 00000000:00000000\n" +
" 2: 00000000:03E1 00000000:0000 0A 00000000:00000000"
);
}
return originalExec(cmd);
});
await ensureContainerMail(box.executor, {
domain: "example.com",
secrets: { PGSQL_ROOT_PASSWD: "pw" },
onLog: () => {},
});
const dbRun = box
.dockerCommands()
.find((c) => c.includes("docker run") && c.includes("postgres:16-alpine"));
expect(dbRun).toBeDefined();
// Retains 5436 from engine.env
expect(dbRun).toContain("-p '127.0.0.1:5436:5432'");
});
});
@@ -31,7 +31,7 @@ import {
} from "../managed-image";
import { dirOf, elevatedExecutor } from "../elevated-executor";
import { resolveEnvironment } from "../environment";
import { waitForPortListening } from "../port-listen";
import { waitForPortListening, probePortListeningOnce } from "../port-listen";
import { rootOrDegrade } from "../privilege";
import {
MAIL_CONTAINER,
@@ -44,7 +44,10 @@ import {
MAIL_DB_NAME,
MAIL_DB_USER,
MAIL_DB_HOST_BIND,
MAIL_DB_PORT,
MAIL_DB_FALLBACK_PORT,
MAIL_DB_PORT_RANGE_MAX,
MAIL_DB_INTERNAL_PORT,
resolveMailDbPort,
type MailMount,
} from "../../infra/mail-container";
@@ -99,15 +102,17 @@ export interface ContainerMailOptions {
image?: string;
container?: string;
dbContainer?: string;
/**
* Host port for the PostgreSQL sidecar. Defaults to `OPENSHIP_MAIL_DB_PORT`
* if set in the environment, otherwise 5432.
*/
dbPort?: number;
/** How long to wait for the mail ports before calling the start a failure. */
verifyTimeoutMs?: number;
}
/** Is a container present (running or stopped)? */
async function containerExists(
executor: CommandExecutor,
container: string,
): Promise<boolean> {
async function containerExists(executor: CommandExecutor, container: string): Promise<boolean> {
return (await containerState(executor, container)) !== null;
}
@@ -130,7 +135,11 @@ function pullFailureMessage(image: string, output: string): string {
.filter(Boolean)
.pop() ?? "";
if (/manifest unknown|manifest for .* not found|not found: manifest|repository .* not found/.test(text)) {
if (
/manifest unknown|manifest for .* not found|not found: manifest|repository .* not found/.test(
text,
)
) {
return (
`The mail engine image ${image} isn't in the registry. ` +
"The engine image isn't published yet, so a server can only run it from a local " +
@@ -143,7 +152,11 @@ function pullFailureMessage(image: string, output: string): string {
"Log this server's Docker into that registry, or set OPENSHIP_MAIL_IMAGE to one it can read."
);
}
if (/timeout|timed out|no such host|temporary failure|network is unreachable|connection refused|i\/o timeout|tls|certificate/.test(text)) {
if (
/timeout|timed out|no such host|temporary failure|network is unreachable|connection refused|i\/o timeout|tls|certificate/.test(
text,
)
) {
return (
`This server couldn't reach the registry to pull ${image} (${lastLine || "network error"}). ` +
"Check its outbound network/DNS and proxy settings, then retry."
@@ -374,15 +387,120 @@ async function retainedDbPassword(
);
}
/**
* For an existing initialised cluster, read the retained database port from ENGINE_ENV_FILE.
* Preserving the previously assigned port prevents repairs/restarts from drifting ports.
*/
export async function retainedDbPort(
executor: CommandExecutor,
onLog?: SystemLogCallback,
): Promise<number | null> {
const initialised = await executor
.exec(`test -s ${sq(`${MAIL_DB_HOST_DATA_DIR}/pgdata/PG_VERSION`)} && echo yes || true`)
.then((out) => out.trim() === "yes")
.catch(() => false);
if (!initialised) return null;
const retained = await readEnvFileValue(executor, ENGINE_ENV_FILE, "OPENSHIP_MAIL_DB_PORT");
if (!retained) return null;
const n = resolveMailDbPort(retained);
onLog?.(
log(
`Reusing existing mail database port ${n} — ${MAIL_DB_HOST_DATA_DIR} already holds ` +
`an initialised cluster.`,
),
);
return n;
}
/**
* Resolve an available host loopback port for the mail database sidecar.
* If the preferred port is free, returns it. If the default 5432 is occupied and
* the port was not explicitly specified, scans up to MAIL_DB_PORT_RANGE_MAX (5460)
* for the first available port.
*/
export async function findAvailableMailDbPort(
executor: CommandExecutor,
preferredPort: number,
isExplicit: boolean,
onLog: SystemLogCallback,
): Promise<number> {
resolveMailDbPort(preferredPort);
const probe = await probePortListeningOnce(executor, preferredPort);
if (probe !== true) {
if (probe === null)
onLog(
log(
`Could not probe mail database port ${preferredPort}; Docker will validate the binding.`,
"warn",
),
);
return preferredPort;
}
// If the user explicitly configured this port, do not auto-switch ports
if (isExplicit) {
throw new Error(
`Configured mail database port ${preferredPort} is already in use. Choose a free OPENSHIP_MAIL_DB_PORT.`,
);
}
// Auto-discovery: default port is occupied; scan candidate range 5433..5460
for (let port = MAIL_DB_FALLBACK_PORT; port <= MAIL_DB_PORT_RANGE_MAX; port++) {
const candidate = await probePortListeningOnce(executor, port);
if (candidate === false) {
onLog(
log(
`Default PostgreSQL port ${preferredPort} is in use on this host. ` +
`Automatically selected available port ${port} for the mail database ` +
`(can be overridden via OPENSHIP_MAIL_DB_PORT).`,
"warn",
),
);
return port;
}
}
throw new Error(
`No free mail database port was found in ${preferredPort}, ${MAIL_DB_FALLBACK_PORT}-${MAIL_DB_PORT_RANGE_MAX}. Set OPENSHIP_MAIL_DB_PORT to a free port.`,
);
}
/** Inspect the existing sidecar without changing it or reading root-only files. */
async function containerDbPort(
executor: CommandExecutor,
container: string,
): Promise<number | null> {
const raw = await executor
.exec(`docker inspect -f '{{json .HostConfig.PortBindings}}' ${sq(container)} 2>/dev/null`)
.catch(() => "");
if (!raw.trim()) return null;
let bindings: Record<string, Array<{ HostIp?: string; HostPort?: string }>>;
try {
bindings = JSON.parse(raw);
} catch {
throw new Error("Could not read the mail database container's port bindings.");
}
const binding = bindings?.[`${MAIL_DB_INTERNAL_PORT}/tcp`];
if (binding?.length !== 1 || binding[0].HostIp !== MAIL_DB_HOST_BIND || !binding[0].HostPort) {
throw new Error(
"The mail database container must publish one PostgreSQL port on host loopback.",
);
}
return resolveMailDbPort(binding[0].HostPort);
}
/** `docker run` argv for the Postgres sidecar (loopback-published, bind-mounted data). */
function buildDbRunCommand(container: string): string {
export function buildDbRunCommand(container: string, dbPort: number = resolveMailDbPort()): string {
resolveMailDbPort(dbPort);
return [
"docker run -d",
`--name ${sq(container)}`,
"--restart unless-stopped",
`--env-file ${sq(DB_ENV_FILE)}`,
`-e ${sq(`PGDATA=${MAIL_DB_PGDATA}`)}`,
`-p ${sq(`${MAIL_DB_HOST_BIND}:${MAIL_DB_PORT}:${MAIL_DB_PORT}`)}`,
`-p ${sq(`${MAIL_DB_HOST_BIND}:${dbPort}:${MAIL_DB_INTERNAL_PORT}`)}`,
`-v ${sq(`${MAIL_DB_HOST_DATA_DIR}:${MAIL_DB_CONTAINER_DATA_DIR}:z`)}`,
sq(MAIL_DB_IMAGE),
].join(" ");
@@ -416,11 +534,15 @@ async function startDb(
executor: CommandExecutor,
container: string,
onLog: SystemLogCallback,
dbPort: number = resolveMailDbPort(),
): Promise<boolean> {
await executor.exec(`docker rm -f ${sq(container)} 2>/dev/null || true`).catch(() => {});
const run = await executor.streamExec(buildDbRunCommand(container), onLog as (l: LogEntry) => void);
const run = await executor.streamExec(
buildDbRunCommand(container, dbPort),
onLog as (l: LogEntry) => void,
);
if (run.code !== 0) return false;
const listening = await waitForPortListening(executor, MAIL_DB_PORT, { timeoutMs: 60_000 });
const listening = await waitForPortListening(executor, dbPort, { timeoutMs: 60_000 });
// checked:false = inconclusive probe; don't fail the DB on a missing /proc read.
return !(listening.checked && !listening.listening);
}
@@ -614,7 +736,13 @@ export async function ensureContainerMail(
const retainedRoot = await retainedDbPassword(hostState, onLog);
const dbRootPassword =
retainedRoot ?? opts.secrets.PGSQL_ROOT_PASSWD ?? opts.secrets.VMAIL_DB_ADMIN_PASSWD ?? "";
const isExplicitPort =
opts.dbPort !== undefined || Boolean(process.env.OPENSHIP_MAIL_DB_PORT?.trim());
const preferredPort = resolveMailDbPort(opts.dbPort);
const retainedPort =
(await retainedDbPort(hostState, onLog)) ?? (await containerDbPort(executor, dbContainer));
const dbPort =
retainedPort ?? (await findAvailableMailDbPort(executor, preferredPort, isExplicitPort, onLog));
await writeEnvFile(hostState, DB_ENV_FILE, {
POSTGRES_USER: "postgres",
POSTGRES_DB: MAIL_DB_NAME,
@@ -622,12 +750,12 @@ export async function ensureContainerMail(
});
await handOverEnvFile(executor, DB_ENV_FILE, onLog);
await writeEnvFile(hostState, ENGINE_ENV_FILE, {
...opts.secrets,
FIRST_DOMAIN: opts.domain,
OPENSHIP_MAIL_DB_HOST: MAIL_DB_HOST_BIND,
OPENSHIP_MAIL_DB_PORT: String(MAIL_DB_PORT),
OPENSHIP_MAIL_DB_PORT: String(dbPort),
OPENSHIP_MAIL_DB_NAME: MAIL_DB_NAME,
OPENSHIP_MAIL_DB_USER: MAIL_DB_USER,
...opts.secrets,
// Spread LAST so the retained value wins: the engine's first-boot bootstrap connects
// as the superuser, and it has to use the password the cluster actually has, not the
// one this deploy generated.
@@ -638,7 +766,7 @@ export async function ensureContainerMail(
try {
// 4. DB sidecar first — the engine's entrypoint blocks on it.
onLog(log("Starting the mail database (postgres sidecar)..."));
if (!(await startDb(executor, dbContainer, onLog))) {
if (!(await startDb(executor, dbContainer, onLog, dbPort))) {
throw new Error("the mail database container failed to become ready");
}
@@ -711,6 +839,10 @@ export async function startContainerMail(
};
}
const dbPort = await containerDbPort(executor, dbContainer);
if (dbPort === null)
return { started: false, reason: "Could not determine the existing mail database port." };
// DB sidecar first: the engine's entrypoint blocks on it.
onLog(log("Starting the mail database (postgres sidecar)..."));
const db = await executor.streamExec(
@@ -720,10 +852,10 @@ export async function startContainerMail(
if (db.code !== 0) {
return { started: false, reason: "the mail database container did not start" };
}
const dbListening = await waitForPortListening(executor, MAIL_DB_PORT, { timeoutMs: 60_000 });
const dbListening = await waitForPortListening(executor, dbPort, { timeoutMs: 60_000 });
// checked:false = inconclusive probe; don't fail on a missing /proc read.
if (dbListening.checked && !dbListening.listening) {
return { started: false, reason: `the mail database is not listening on :${MAIL_DB_PORT}` };
return { started: false, reason: `the mail database is not listening on :${dbPort}` };
}
onLog(log("Starting the mail engine container..."));