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}).`, + }; + }); }