From 390ab4efb3f98eabcaae367027916bf5326d4dd1 Mon Sep 17 00:00:00 2001
From: farrasrayhand
Date: Fri, 7 Aug 2026 19:44:21 +0800
Subject: [PATCH 1/4] feat(mail): add API and Web Dashboard action to migrate
host mail engine to Docker container
---
.../modules/mail/admin/admin.controller.ts | 18 +++++++++
.../mail/admin/migrate-engine.service.ts | 39 ++++++++++++++++++
apps/api/src/modules/mail/mail.routes.ts | 5 +++
.../emails/_components/admin/advanced-tab.tsx | 40 ++++++++++++++++++-
apps/dashboard/src/lib/api/endpoints.ts | 2 +
apps/dashboard/src/lib/api/mail-admin.ts | 4 ++
6 files changed, 106 insertions(+), 2 deletions(-)
create mode 100644 apps/api/src/modules/mail/admin/migrate-engine.service.ts
diff --git a/apps/api/src/modules/mail/admin/admin.controller.ts b/apps/api/src/modules/mail/admin/admin.controller.ts
index 9da4b850a..8f5f2a023 100644
--- a/apps/api/src/modules/mail/admin/admin.controller.ts
+++ b/apps/api/src/modules/mail/admin/admin.controller.ts
@@ -765,6 +765,24 @@ export async function restartAllComponentsHandler(c: Context) {
}
}
+export async function migrateToContainerHandler(c: Context) {
+ const guard = assertNotCloud(c);
+ if (guard) return guard;
+ const serverId = param(c, "serverId");
+ await permission.assert(getRequestContext(c), { resourceType: "mail_server", resourceId: serverId, action: "admin" });
+ const ctx = getRequestContext(c);
+ if (!(await isServerInOrg(ctx, serverId))) {
+ return c.json({ error: "Server not found" }, 404);
+ }
+ try {
+ const { migrateMailEngineToContainer } = await import("./migrate-engine.service");
+ const result = await migrateMailEngineToContainer(serverId);
+ return c.json(result);
+ } catch (err) {
+ return errorJson(c, err);
+ }
+}
+
export async function getComponentLogsHandler(c: Context) {
const guard = assertNotCloud(c);
if (guard) return guard;
diff --git a/apps/api/src/modules/mail/admin/migrate-engine.service.ts b/apps/api/src/modules/mail/admin/migrate-engine.service.ts
new file mode 100644
index 000000000..d1c0b34a6
--- /dev/null
+++ b/apps/api/src/modules/mail/admin/migrate-engine.service.ts
@@ -0,0 +1,39 @@
+/**
+ * Service to migrate a legacy host-native mail engine to a containerized engine.
+ */
+
+import { detectMailEngine, ensureContainerMail } from "@repo/adapters";
+import { acquireMailExecutor, readState } from "../mail-engine";
+
+export async function migrateMailEngineToContainer(
+ serverId: string,
+): Promise<{ migrated: boolean; message: string }> {
+ const executor = await acquireMailExecutor(serverId);
+ const probe = await detectMailEngine(executor);
+
+ if (probe.flavor === "container") {
+ return { migrated: false, message: "Mail server is already running in container mode." };
+ }
+
+ const state = await readState(executor);
+ if (!state) {
+ throw new Error("Mail server state not found — cannot migrate.");
+ }
+
+ // 1. Stop and disable legacy host-native mail daemons
+ await executor.exec("systemctl stop postfix dovecot amavis iredapd 2>/dev/null || true");
+ await executor.exec("systemctl disable postfix dovecot amavis iredapd 2>/dev/null || true");
+
+ // 2. Provision and start the containerized mail engine + DB sidecar
+ const logs: string[] = [];
+ const result = await ensureContainerMail(executor, {
+ domain: state.domain,
+ secrets: state.secrets ?? {},
+ onLog: (l) => logs.push(l.message),
+ });
+
+ return {
+ migrated: true,
+ message: `Mail server migrated successfully to container mode (image: ${result.image}).`,
+ };
+}
diff --git a/apps/api/src/modules/mail/mail.routes.ts b/apps/api/src/modules/mail/mail.routes.ts
index fdd0fd27b..70db3a491 100644
--- a/apps/api/src/modules/mail/mail.routes.ts
+++ b/apps/api/src/modules/mail/mail.routes.ts
@@ -203,6 +203,11 @@ r.post(
{ tag: "mail_server:admin" },
admin.restartAllComponentsHandler,
);
+r.post(
+ "/admin/:serverId/migrate-to-container",
+ { tag: "mail_server:admin" },
+ admin.migrateToContainerHandler,
+);
r.post(
"/admin/:serverId/components/:key/:action",
{ tag: "mail_server:admin" },
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/advanced-tab.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/advanced-tab.tsx
index 457839bf2..c67431413 100644
--- a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/advanced-tab.tsx
+++ b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/advanced-tab.tsx
@@ -486,8 +486,8 @@ function MailStackToolsSection({ serverId }: { serverId: string }) {
-
-
+
+
+
+
+
+
+
+
+
+ Migrate Host Mail to Docker Container
+
+
+ Convert a legacy host-native (bare-metal systemd) mail installation to a containerized Docker engine with Postgres sidecar without losing accounts or mail.
+
+
+
+
);
diff --git a/apps/dashboard/src/lib/api/endpoints.ts b/apps/dashboard/src/lib/api/endpoints.ts
index d71981938..1284f2b5f 100644
--- a/apps/dashboard/src/lib/api/endpoints.ts
+++ b/apps/dashboard/src/lib/api/endpoints.ts
@@ -392,6 +392,8 @@ export const endpoints = {
`mail/admin/${encodeURIComponent(serverId)}/components/${encodeURIComponent(key)}/logs`,
componentsRestartAll: (serverId: string) =>
`mail/admin/${encodeURIComponent(serverId)}/components/restart-all`,
+ migrateToContainer: (serverId: string) =>
+ `mail/admin/${encodeURIComponent(serverId)}/migrate-to-container`,
},
webmail: {
targets: "mail/webmail/targets",
diff --git a/apps/dashboard/src/lib/api/mail-admin.ts b/apps/dashboard/src/lib/api/mail-admin.ts
index a321e3a24..57770b7fc 100644
--- a/apps/dashboard/src/lib/api/mail-admin.ts
+++ b/apps/dashboard/src/lib/api/mail-admin.ts
@@ -405,6 +405,10 @@ export const mailAdminApi = {
api.post(
endpoints.mail.admin.componentsRestartAll(serverId),
),
+ migrateToContainer: (serverId: string) =>
+ api.post<{ migrated: boolean; message: string }>(
+ endpoints.mail.admin.migrateToContainer(serverId),
+ ),
},
};
From 059595f2e53d1409e1d7499ff82b22edaad7ee61 Mon Sep 17 00:00:00 2001
From: farrasrayhand
Date: Fri, 7 Aug 2026 19:59:39 +0800
Subject: [PATCH 2/4] fix(dashboard): fix showToast type argument in
advanced-tab.tsx
---
.../app/(dashboard)/emails/_components/admin/advanced-tab.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/advanced-tab.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/advanced-tab.tsx
index c67431413..3440b1518 100644
--- a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/advanced-tab.tsx
+++ b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/advanced-tab.tsx
@@ -533,7 +533,7 @@ function MailStackToolsSection({ serverId }: { serverId: string }) {
setRestarting(true);
try {
const res = await mailAdminApi.components.migrateToContainer(serverId);
- showToast(res.message, res.migrated ? "success" : "info");
+ showToast(res.message, res.migrated ? "success" : "error");
} catch (err) {
showToast(err instanceof Error ? err.message : "Migration failed", "error");
} finally {
From b7f51f9cf000c05129d9aeee7d5eac6361d05461 Mon Sep 17 00:00:00 2001
From: farrasrayhand
Date: Fri, 7 Aug 2026 20:03:16 +0800
Subject: [PATCH 3/4] fix(mail): import readState from mail-state instead of
mail-engine
---
apps/api/src/modules/mail/admin/migrate-engine.service.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/modules/mail/admin/migrate-engine.service.ts b/apps/api/src/modules/mail/admin/migrate-engine.service.ts
index d1c0b34a6..60aabec0e 100644
--- a/apps/api/src/modules/mail/admin/migrate-engine.service.ts
+++ b/apps/api/src/modules/mail/admin/migrate-engine.service.ts
@@ -3,7 +3,8 @@
*/
import { detectMailEngine, ensureContainerMail } from "@repo/adapters";
-import { acquireMailExecutor, readState } from "../mail-engine";
+import { acquireMailExecutor } from "../mail-engine";
+import { readState } from "../mail-state";
export async function migrateMailEngineToContainer(
serverId: string,
From 7d6341dab2b991fb890accebd8b59d52995b2238 Mon Sep 17 00:00:00 2001
From: farrasrayhand
Date: Fri, 7 Aug 2026 20:07:35 +0800
Subject: [PATCH 4/4] fix(mail): use sshManager.withExecutor to acquire
executor for serverId
---
.../mail/admin/migrate-engine.service.ts | 49 ++++++++++---------
1 file changed, 25 insertions(+), 24 deletions(-)
diff --git a/apps/api/src/modules/mail/admin/migrate-engine.service.ts b/apps/api/src/modules/mail/admin/migrate-engine.service.ts
index 60aabec0e..aab0155e4 100644
--- a/apps/api/src/modules/mail/admin/migrate-engine.service.ts
+++ b/apps/api/src/modules/mail/admin/migrate-engine.service.ts
@@ -3,38 +3,39 @@
*/
import { detectMailEngine, ensureContainerMail } from "@repo/adapters";
-import { acquireMailExecutor } from "../mail-engine";
+import { sshManager } from "../../../lib/ssh-manager";
import { readState } from "../mail-state";
export async function migrateMailEngineToContainer(
serverId: string,
): Promise<{ migrated: boolean; message: string }> {
- const executor = await acquireMailExecutor(serverId);
- const probe = await detectMailEngine(executor);
+ return sshManager.withExecutor(serverId, async (executor) => {
+ const probe = await detectMailEngine(executor);
- if (probe.flavor === "container") {
- return { migrated: false, message: "Mail server is already running in container mode." };
- }
+ if (probe.flavor === "container") {
+ return { migrated: false, message: "Mail server is already running in container mode." };
+ }
- const state = await readState(executor);
- if (!state) {
- throw new Error("Mail server state not found — cannot migrate.");
- }
+ const state = await readState(executor);
+ if (!state) {
+ throw new Error("Mail server state not found — cannot migrate.");
+ }
- // 1. Stop and disable legacy host-native mail daemons
- await executor.exec("systemctl stop postfix dovecot amavis iredapd 2>/dev/null || true");
- await executor.exec("systemctl disable postfix dovecot amavis iredapd 2>/dev/null || true");
+ // 1. Stop and disable legacy host-native mail daemons
+ await executor.exec("systemctl stop postfix dovecot amavis iredapd 2>/dev/null || true");
+ await executor.exec("systemctl disable postfix dovecot amavis iredapd 2>/dev/null || true");
- // 2. Provision and start the containerized mail engine + DB sidecar
- const logs: string[] = [];
- const result = await ensureContainerMail(executor, {
- domain: state.domain,
- secrets: state.secrets ?? {},
- onLog: (l) => logs.push(l.message),
- });
+ // 2. Provision and start the containerized mail engine + DB sidecar
+ const logs: string[] = [];
+ const result = await ensureContainerMail(executor, {
+ domain: state.domain,
+ secrets: state.secrets ?? {},
+ onLog: (l) => logs.push(l.message),
+ });
- return {
- migrated: true,
- message: `Mail server migrated successfully to container mode (image: ${result.image}).`,
- };
+ return {
+ migrated: true,
+ message: `Mail server migrated successfully to container mode (image: ${result.image}).`,
+ };
+ });
}