fix
This commit is contained in:
@@ -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'}`);
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
await run('docker', ['ps', '--format', '{{.Names}}'], 30000);
|
||||
}
|
||||
|
||||
async listAccounts(): Promise<DmsAccount[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const normalized = normalizeEmail(email);
|
||||
// docker-mailserver supports setup email update <email> <password> 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<void> {
|
||||
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<DmsQuota> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ShellResult> {
|
||||
export function run(
|
||||
command: string,
|
||||
args: string[] = [],
|
||||
timeoutMs = 120000,
|
||||
): Promise<RunResult> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user