From 8178cd0a08d9a1c4fa4c9b56ac4cf5e238af1a38 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Sun, 26 Apr 2026 16:19:09 -0500 Subject: [PATCH] fix --- backend/src/server.ts | 57 ++++++++++++-------- backend/src/services/dms.ts | 103 +++++++++++++++++++++++++++++++----- backend/src/utils/shell.ts | 68 ++++++++++++++++++++---- 3 files changed, 184 insertions(+), 44 deletions(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index d6efa6f..b8b9278 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,46 +1,59 @@ import express from 'express'; import cookieParser from 'cookie-parser'; -import cors from 'cors'; -import { dirname, resolve } from 'node:path'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { config } from './config.js'; import { initDb } from './db.js'; import { authRouter } from './routes/auth.js'; import { domainsRouter } from './routes/domains.js'; import { mailboxesRouter } from './routes/mailboxes.js'; import { auditRouter } from './routes/audit.js'; -import { SyncService } from './services/sync.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); const app = express(); -app.use(cors({ origin: true, credentials: true })); -app.use(express.json({ limit: '1mb' })); +app.use(express.json({ limit: '2mb' })); app.use(cookieParser()); -app.get('/api/health', (_req, res) => res.json({ ok: true, node: config.nodeName, hostname: config.nodeHostname })); +app.use((req, res, next) => { + const started = Date.now(); + + res.on('finish', () => { + const ms = Date.now() - started; + console.log(`[http] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`); + }); + + next(); +}); + app.use('/api/auth', authRouter); app.use('/api/domains', domainsRouter); app.use('/api/mailboxes', mailboxesRouter); app.use('/api/audit', auditRouter); -app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - const status = err.status ?? 500; - console.error(err); - res.status(status).json({ error: err.message ?? 'Internal server error' }); +const currentFile = fileURLToPath(import.meta.url); +const currentDir = path.dirname(currentFile); +const publicDir = path.resolve(currentDir, '../public'); + +app.use(express.static(publicDir)); + +app.get('*', (_req, res) => { + res.sendFile(path.join(publicDir, 'index.html')); }); -const publicDir = config.publicDir.startsWith('/') ? config.publicDir : resolve(__dirname, config.publicDir); -app.use(express.static(publicDir)); -app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html'))); +app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => { + const status = err.status || err.statusCode || 500; + + console.error(`[error] ${req.method} ${req.originalUrl}`); + console.error(err?.stack || err?.message || err); + + res.status(status).json({ + error: err?.message || 'Internal server error', + }); +}); await initDb(); -try { - await new SyncService().syncFromDms(); -} catch (err) { - console.warn('Initial DMS sync failed. The app still starts:', err); -} -app.listen(config.port, () => { - console.log(`mailadmin listening on ${config.port} for ${config.nodeName}`); +const port = Number(process.env.PORT || 3000); + +app.listen(port, () => { + console.log(`mailadmin listening on ${port} for ${process.env.NODE_NAME || 'unknown-node'}`); }); diff --git a/backend/src/services/dms.ts b/backend/src/services/dms.ts index 56b3d90..0c6a799 100644 --- a/backend/src/services/dms.ts +++ b/backend/src/services/dms.ts @@ -19,17 +19,26 @@ export interface DmsQuota { function parseAccounts(output: string): DmsAccount[] { const accounts: DmsAccount[] = []; + for (const line of output.split('\n')) { const match = line.match(/([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})/i); if (!match) continue; + const email = normalizeEmail(match[1]); - accounts.push({ email, localPart: localPartFromEmail(email), domain: domainFromEmail(email) }); + accounts.push({ + email, + localPart: localPartFromEmail(email), + domain: domainFromEmail(email), + }); } - return [...new Map(accounts.map((a) => [a.email, a])).values()].sort((a, b) => a.email.localeCompare(b.email)); + + return [...new Map(accounts.map((a) => [a.email, a])).values()] + .sort((a, b) => a.email.localeCompare(b.email)); } function parseQuotaNumber(value: string | undefined): number | null { if (!value || value === '-' || value.trim() === '') return null; + const parsed = Number.parseInt(value.trim(), 10); return Number.isFinite(parsed) ? parsed : null; } @@ -47,6 +56,7 @@ function parseDoveadmQuota(output: string): DmsQuota { const parts = trimmed.split(/\s+/); const typeIndex = parts.findIndex((p) => p === 'STORAGE' || p === 'MESSAGE'); + if (typeIndex < 0) continue; const type = parts[typeIndex]; @@ -67,11 +77,13 @@ function parseDoveadmQuota(output: string): DmsQuota { } const usedBytes = Math.max(0, usedStorageKb ?? 0) * 1024; - const quotaBytes = storageLimitKb && storageLimitKb > 0 ? storageLimitKb * 1024 : null; + const quotaBytes = storageLimitKb && storageLimitKb > 0 + ? storageLimitKb * 1024 + : null; - // Dovecot's % column is integer-rounded. For the UI we calculate a more useful - // value ourselves, so small accounts do not always show exactly 0%. - const calculatedPercent = quotaBytes && quotaBytes > 0 ? (usedBytes / quotaBytes) * 100 : null; + const calculatedPercent = quotaBytes && quotaBytes > 0 + ? (usedBytes / quotaBytes) * 100 + : null; return { usedBytes, @@ -83,48 +95,113 @@ function parseDoveadmQuota(output: string): DmsQuota { } export class DmsService { + async assertDockerAccess(): Promise { + await run('docker', ['ps', '--format', '{{.Names}}'], 30000); + } + async listAccounts(): Promise { - const { stdout } = await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'list']); - return parseAccounts(stdout); + console.log(`[dms] listing accounts from container: ${config.dmsContainer}`); + + const { stdout } = await run( + 'docker', + ['exec', config.dmsContainer, 'setup', 'email', 'list'], + 60000, + ); + + const accounts = parseAccounts(stdout); + + console.log(`[dms] found ${accounts.length} account(s)`); + + return accounts; } async addMailbox(email: string, password: string): Promise { const normalized = normalizeEmail(email); + + console.log(`[dms] creating mailbox: ${normalized}`); + + await this.assertDockerAccess(); + if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) { + console.log(`[dms] using manage script: ${config.manageMailUserScript}`); await run(config.manageMailUserScript, ['add', normalized, password]); + console.log(`[dms] mailbox created through manage script: ${normalized}`); return; } - await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'add', normalized, password]); + + console.log(`[dms] using docker-mailserver setup command`); + + await run( + 'docker', + ['exec', config.dmsContainer, 'setup', 'email', 'add', normalized, password], + 120000, + ); + + console.log(`[dms] mailbox created: ${normalized}`); } async deleteMailbox(email: string): Promise { const normalized = normalizeEmail(email); + + console.log(`[dms] deleting mailbox: ${normalized}`); + + await this.assertDockerAccess(); + if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) { + console.log(`[dms] using manage script: ${config.manageMailUserScript}`); await run(config.manageMailUserScript, ['del', normalized]); + console.log(`[dms] mailbox deleted through manage script: ${normalized}`); return; } - await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'del', normalized]); + + await run( + 'docker', + ['exec', config.dmsContainer, 'setup', 'email', 'del', normalized], + 120000, + ); + + console.log(`[dms] mailbox deleted: ${normalized}`); } async updatePassword(email: string, password: string): Promise { const normalized = normalizeEmail(email); - // docker-mailserver supports setup email update in current versions. - await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'update', normalized, password]); + + console.log(`[dms] updating password for mailbox: ${normalized}`); + + await this.assertDockerAccess(); + + await run( + 'docker', + ['exec', config.dmsContainer, 'setup', 'email', 'update', normalized, password], + 120000, + ); + + console.log(`[dms] password updated for mailbox: ${normalized}`); } async syncSesDomain(domain: string): Promise { + const normalizedDomain = domain.toLowerCase(); + if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) { - await run(config.manageMailUserScript, ['sync', domain.toLowerCase()]); + console.log(`[dms] syncing SES for domain through manage script: ${normalizedDomain}`); + await run(config.manageMailUserScript, ['sync', normalizedDomain], 120000); + return; } + + console.log(`[dms] no manage script configured, skipping SES sync for ${normalizedDomain}`); } async getMailboxQuota(email: string): Promise { const normalized = normalizeEmail(email); + + console.log(`[dms] reading quota for mailbox: ${normalized}`); + const { stdout } = await run( 'docker', ['exec', config.dmsContainer, 'doveadm', 'quota', 'get', '-u', normalized], 60000, ); + return parseDoveadmQuota(stdout); } } \ No newline at end of file diff --git a/backend/src/utils/shell.ts b/backend/src/utils/shell.ts index ed67053..142340f 100644 --- a/backend/src/utils/shell.ts +++ b/backend/src/utils/shell.ts @@ -1,21 +1,71 @@ -import { execFile } from 'node:child_process'; +import { spawn } from 'node:child_process'; -export interface ShellResult { +export interface RunResult { stdout: string; stderr: string; + code: number; } -export function run(cmd: string, args: string[], timeoutMs = 120000): Promise { +export function run( + command: string, + args: string[] = [], + timeoutMs = 120000, +): Promise { return new Promise((resolve, reject) => { - execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { - if (error) { - const err = new Error(`Command failed: ${cmd} ${args.join(' ')}\n${stderr || error.message}`); - (err as any).stdout = stdout; - (err as any).stderr = stderr; + const printable = [command, ...args].join(' '); + console.log(`[shell] running: ${printable}`); + + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }); + + let stdout = ''; + let stderr = ''; + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`Command timed out after ${timeoutMs}ms: ${printable}`)); + }, timeoutMs); + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', (err) => { + clearTimeout(timer); + console.error(`[shell] failed to start: ${printable}`); + console.error(`[shell] error: ${err.message}`); + reject(err); + }); + + child.on('close', (code) => { + clearTimeout(timer); + + const exitCode = code ?? -1; + + if (stdout.trim()) { + console.log(`[shell] stdout for ${printable}:\n${stdout.trim()}`); + } + + if (stderr.trim()) { + console.warn(`[shell] stderr for ${printable}:\n${stderr.trim()}`); + } + + if (exitCode !== 0) { + const err = new Error( + `Command failed with exit code ${exitCode}: ${printable}\n${stderr || stdout}`, + ); reject(err); return; } - resolve({ stdout, stderr }); + + console.log(`[shell] completed: ${printable}`); + resolve({ stdout, stderr, code: exitCode }); }); }); }